mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 18:43:08 +08:00
feat(settings): refine skills and MCP action controls
This commit is contained in:
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types';
|
import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types';
|
||||||
import { IS_PLATFORM } from '../../../constants/config';
|
import { IS_PLATFORM } from '../../../constants/config';
|
||||||
import { Badge, Button } from '../../../shared/view/ui';
|
import { ActionMenu, Badge, Button } from '../../../shared/view/ui';
|
||||||
import {
|
import {
|
||||||
MCP_GLOBAL_SUPPORTED_SCOPES,
|
MCP_GLOBAL_SUPPORTED_SCOPES,
|
||||||
MCP_GLOBAL_SUPPORTED_TRANSPORTS,
|
MCP_GLOBAL_SUPPORTED_TRANSPORTS,
|
||||||
@@ -134,33 +134,39 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<Server className="h-5 w-5 text-purple-500" />
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
<Server className="mt-0.5 h-5 w-5 flex-shrink-0 text-purple-500" />
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionMenu
|
||||||
|
label="Add MCP Server"
|
||||||
|
icon={Plus}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
triggerClassName={`w-full sm:w-auto ${MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}`}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'global',
|
||||||
|
label: globalButtonLabel,
|
||||||
|
description: globalAddDescription,
|
||||||
|
icon: Globe,
|
||||||
|
onSelect: openGlobalForm,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'provider',
|
||||||
|
label: providerButtonLabel,
|
||||||
|
description: providerAddDescription,
|
||||||
|
icon: Server,
|
||||||
|
onSelect: () => openForm(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={openGlobalForm}
|
|
||||||
className={MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}
|
|
||||||
size="sm"
|
|
||||||
title={globalAddDescription}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{globalButtonLabel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => openForm()}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
title={providerAddDescription}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{providerButtonLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-4">
|
<div className="min-h-4">
|
||||||
{saveStatus === 'success' && (
|
{saveStatus === 'success' && (
|
||||||
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>
|
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FileUp,
|
FileUp,
|
||||||
FolderUp,
|
FolderUp,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -18,11 +19,6 @@ import { cn } from '../../../lib/utils';
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Input,
|
Input,
|
||||||
} from '../../../shared/view/ui';
|
} from '../../../shared/view/ui';
|
||||||
import { useProviderSkills } from '../hooks/useProviderSkills';
|
import { useProviderSkills } from '../hooks/useProviderSkills';
|
||||||
@@ -216,6 +212,8 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isAddPanelOpen, setIsAddPanelOpen] = useState(false);
|
||||||
|
const [showInstallPath, setShowInstallPath] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -227,6 +225,8 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
|
setIsAddPanelOpen(false);
|
||||||
|
setShowInstallPath(false);
|
||||||
}, [selectedProvider]);
|
}, [selectedProvider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -354,6 +354,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
})));
|
})));
|
||||||
await addSkills({ entries });
|
await addSkills({ entries });
|
||||||
setQueuedFiles([]);
|
setQueuedFiles([]);
|
||||||
|
setIsAddPanelOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
|
setSubmitError(error instanceof Error ? error.message : 'Failed to import skills');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -361,294 +362,323 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
|
|||||||
}
|
}
|
||||||
}, [addSkills, queuedFiles]);
|
}, [addSkills, queuedFiles]);
|
||||||
|
|
||||||
return (
|
const uploadPanel = (
|
||||||
<div className="min-w-0 space-y-4 overflow-x-hidden">
|
<div className="space-y-4 rounded-lg border border-border/70 bg-background/80 p-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
<div
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
{...getRootProps()}
|
||||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
|
className={cn(
|
||||||
<FileCode2 className="h-4 w-4" strokeWidth={1.7} />
|
'rounded-lg border border-dashed p-4 transition-colors sm:p-5',
|
||||||
|
isDragActive
|
||||||
|
? 'border-foreground/40 bg-muted/35'
|
||||||
|
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".md,text/markdown"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
handleDrop(Array.from(event.target.files ?? []));
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={folderInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
handleFolderSelection(Array.from(event.target.files ?? []));
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center">
|
||||||
|
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium text-foreground">Drop a skill folder or SKILL.md</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Folders can include scripts, references, and assets.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||||
<h3 className="text-lg font-medium text-foreground">{t('tabs.skills', { defaultValue: 'Skills' })}</h3>
|
<Button
|
||||||
<p className="text-sm text-muted-foreground">
|
type="button"
|
||||||
Install global {providerName} skills from `.md` files or complete skill folders.
|
variant="outline"
|
||||||
</p>
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<FileUp className="h-4 w-4" />
|
||||||
|
Choose Files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => folderInputRef.current?.click()}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<FolderUp className="h-4 w-4" />
|
||||||
|
Choose Folder
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => void refreshSkills({ force: true })}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
disabled={isLoading || isLoadingProjectScopes}
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
|
{queuedFiles.length > 0 && (
|
||||||
<CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="text-sm font-medium text-foreground">Ready to install</div>
|
||||||
<div className="text-sm font-medium text-foreground">Upload Skills</div>
|
<div className="grid gap-2">
|
||||||
<div className="min-w-0 rounded-2xl border border-border/60 bg-background/70 p-3">
|
{queuedFiles.map((queuedFile) => (
|
||||||
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Install Path</div>
|
<div
|
||||||
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
|
key={queuedFile.id}
|
||||||
</div>
|
className="flex items-center gap-3 rounded-lg border border-border/70 bg-background/70 px-3 py-2"
|
||||||
</div>
|
>
|
||||||
</CardHeader>
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||||
|
{queuedFile.kind === 'folder' ? <FolderUp className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
|
||||||
<CardContent className="space-y-4 p-4">
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="min-w-0 flex-1">
|
||||||
<div
|
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
|
||||||
{...getRootProps()}
|
<div className="text-xs text-muted-foreground">
|
||||||
className={cn(
|
{queuedFile.kind === 'folder'
|
||||||
'rounded-3xl border border-dashed p-4 transition-colors sm:p-5',
|
? `${queuedFile.files.length} files`
|
||||||
isDragActive
|
: 'Markdown file'}
|
||||||
? 'border-foreground/40 bg-muted/35'
|
{' · '}
|
||||||
: 'border-border/70 bg-muted/15 hover:border-foreground/25 hover:bg-muted/25',
|
{formatFileSize(queuedFile.size)}
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".md,text/markdown"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(event) => {
|
|
||||||
handleDrop(Array.from(event.target.files ?? []));
|
|
||||||
event.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={folderInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(event) => {
|
|
||||||
handleFolderSelection(Array.from(event.target.files ?? []));
|
|
||||||
event.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-4 text-center sm:py-6">
|
|
||||||
<FileUp className="h-7 w-7 text-muted-foreground" strokeWidth={1.5} />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-sm font-medium text-foreground">Drop `.md` files or skill folders here</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Upload standalone definitions or choose a full folder to include its scripts, references, and assets.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="ghost"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
className="h-8 w-8 flex-shrink-0 p-0 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
aria-label={`Remove ${queuedFile.name}`}
|
||||||
className="w-full sm:w-auto"
|
onClick={() => {
|
||||||
>
|
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
|
||||||
<FileUp className="h-4 w-4" />
|
}}
|
||||||
Choose Files
|
>
|
||||||
</Button>
|
<X className="h-4 w-4" />
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => folderInputRef.current?.click()}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<FolderUp className="h-4 w-4" />
|
|
||||||
Choose Folder
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{providerPath && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
onClick={() => setShowInstallPath((current) => !current)}
|
||||||
|
>
|
||||||
|
{showInstallPath ? 'Hide install location' : 'Where will this install?'}
|
||||||
|
</button>
|
||||||
|
{showInstallPath && (
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/15 p-3">
|
||||||
|
<code className="block whitespace-normal break-all text-xs text-foreground">{providerPath}</code>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{queuedFiles.length > 0 && (
|
<div className="flex flex-col gap-3 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="space-y-2">
|
<span className="text-xs text-muted-foreground">
|
||||||
<div className="text-sm font-medium text-foreground">Queued Files</div>
|
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
|
||||||
<div className="grid gap-2">
|
</span>
|
||||||
{queuedFiles.map((queuedFile) => (
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center">
|
||||||
<div
|
<Button
|
||||||
key={queuedFile.id}
|
type="button"
|
||||||
className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/70 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2"
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
<div className="min-w-0">
|
className="w-full sm:w-auto"
|
||||||
<div className="truncate text-sm font-medium text-foreground">{queuedFile.name}</div>
|
onClick={() => {
|
||||||
<div className="text-xs text-muted-foreground">
|
setQueuedFiles([]);
|
||||||
{queuedFile.kind === 'folder'
|
setSubmitError(null);
|
||||||
? `${queuedFile.files.length} files`
|
setIsAddPanelOpen(false);
|
||||||
: 'Markdown file'}
|
}}
|
||||||
{' · '}
|
>
|
||||||
{formatFileSize(queuedFile.size)}
|
Cancel
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
size="sm"
|
||||||
variant="ghost"
|
className="w-full sm:w-auto"
|
||||||
size="sm"
|
onClick={() => void handleUploadInstall()}
|
||||||
className="w-full sm:w-auto"
|
disabled={isSubmitting || queuedFiles.length === 0}
|
||||||
onClick={() => {
|
>
|
||||||
setQueuedFiles((previous) => previous.filter((file) => file.id !== queuedFile.id));
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
}}
|
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skill'}
|
||||||
>
|
</Button>
|
||||||
Remove
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
return (
|
||||||
<Button
|
<div className="min-w-0 space-y-4 overflow-x-hidden">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20 text-muted-foreground">
|
||||||
|
<FileCode2 className="h-4 w-4" strokeWidth={1.7} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<h3 className="text-lg font-medium text-foreground">{t('tabs.skills', { defaultValue: 'Skills' })}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage {providerName} skills from local files, complete folders, and project-aware locations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative min-w-0 flex-1 sm:max-w-md">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search skills..."
|
||||||
|
aria-label="Search skills"
|
||||||
|
className="h-9 w-full pl-9 pr-9"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleUploadInstall()}
|
onClick={() => setSearchQuery('')}
|
||||||
disabled={isSubmitting}
|
aria-label="Clear skill search"
|
||||||
className="w-full sm:w-auto"
|
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
<X className="h-3.5 w-3.5" />
|
||||||
Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'}
|
</button>
|
||||||
</Button>
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
</div>
|
||||||
Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setSubmitError(null);
|
||||||
|
setShowInstallPath(false);
|
||||||
|
setIsAddPanelOpen((current) => !current);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Skill
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void refreshSkills({ force: true })}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
disabled={isLoading || isLoadingProjectScopes}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', (isLoading || isLoadingProjectScopes) && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isLoadingProjectScopes && (
|
||||||
|
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Scanning project skills...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAddPanelOpen && uploadPanel}
|
||||||
|
|
||||||
|
{(submitError || loadError) && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
|
||||||
|
{submitError || loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveStatus === 'success' && !isAddPanelOpen && (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Skills saved successfully.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{isLoading && skills.length === 0 && (
|
||||||
|
<div className="flex min-h-[180px] items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Loading {providerName} skills…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && skills.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-lg border border-border/60 bg-background/80 text-muted-foreground">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm font-medium text-foreground">No skills discovered yet</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Add a global skill above or create project-specific skill folders in your workspace.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
|
||||||
|
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||||
|
<div className="mt-3 text-sm font-medium text-foreground">No matching skills</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Try a different command, name, scope, project, or source path.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupedSkills.map((group) => (
|
||||||
|
<section key={group.scope} className="min-w-0 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className={cn('rounded-full px-2.5 py-1 text-xs', SCOPE_BADGE_CLASSES[group.scope])}>
|
||||||
|
{SCOPE_LABELS[group.scope]}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{(submitError || loadError) && (
|
<div className="grid min-w-0 gap-3 lg:grid-cols-2">
|
||||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
|
{group.skills.map((skill) => (
|
||||||
{submitError || loadError}
|
<div
|
||||||
</div>
|
key={`${skill.command}:${skill.sourcePath}:${skill.projectPath || 'global'}`}
|
||||||
)}
|
className="min-w-0 rounded-lg border border-border bg-card/50 p-4"
|
||||||
|
|
||||||
{saveStatus === 'success' && (
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
|
||||||
Skills saved successfully.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="min-w-0 border-border/70 bg-background/80 shadow-sm">
|
|
||||||
<CardHeader className="border-b border-border/60">
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<CardTitle>Visible Skills</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
The list below comes from the provider skill discovery API and includes global and project-aware locations.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="relative w-full lg:w-72">
|
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(event) => setSearchQuery(event.target.value)}
|
|
||||||
placeholder="Search skills..."
|
|
||||||
aria-label="Search visible skills"
|
|
||||||
className="h-9 w-full pl-9 pr-9"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
aria-label="Clear skill search"
|
|
||||||
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<div className="min-w-0 space-y-1">
|
||||||
</button>
|
<div className="break-all font-mono text-sm font-semibold text-foreground">{skill.command}</div>
|
||||||
)}
|
<div className="text-sm text-muted-foreground">{skill.name}</div>
|
||||||
</div>
|
|
||||||
{isLoadingProjectScopes && (
|
|
||||||
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
Scanning project skills…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-5 p-4">
|
|
||||||
{isLoading && skills.length === 0 && (
|
|
||||||
<div className="flex min-h-[180px] items-center justify-center text-sm text-muted-foreground">
|
|
||||||
Loading {providerName} skills…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && skills.length === 0 && (
|
|
||||||
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
|
|
||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-muted-foreground">
|
|
||||||
<FileText className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 text-sm font-medium text-foreground">No skills discovered yet</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Add a global skill above or create project-specific skill folders in your workspace.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && skills.length > 0 && filteredSkills.length === 0 && (
|
|
||||||
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/15 px-4 py-10 text-center">
|
|
||||||
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
|
|
||||||
<div className="mt-3 text-sm font-medium text-foreground">No matching skills</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Try a different command, name, scope, project, or source path.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{groupedSkills.map((group) => (
|
|
||||||
<section key={group.scope} className="min-w-0 space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className={cn('rounded-full px-2.5 py-1 text-xs', SCOPE_BADGE_CLASSES[group.scope])}>
|
|
||||||
{SCOPE_LABELS[group.scope]}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
|
||||||
{group.skills.length} skill{group.skills.length === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid min-w-0 gap-3 lg:grid-cols-2">
|
|
||||||
{group.skills.map((skill) => (
|
|
||||||
<div
|
|
||||||
key={`${skill.command}:${skill.sourcePath}:${skill.projectPath || 'global'}`}
|
|
||||||
className="min-w-0 rounded-3xl border border-border/70 bg-gradient-to-br from-background via-background to-muted/25 p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 space-y-1">
|
|
||||||
<div className="break-all font-mono text-sm font-semibold text-foreground">{skill.command}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{skill.name}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{skill.description || 'No description provided in the skill front matter.'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
||||||
{skill.pluginName && (
|
|
||||||
<Badge variant="outline" className="rounded-full bg-background/70">
|
|
||||||
Plugin: {skill.pluginName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{skill.projectDisplayName && (
|
|
||||||
<Badge variant="outline" className="rounded-full bg-background/70">
|
|
||||||
Project: {skill.projectDisplayName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 min-w-0 rounded-2xl border border-border/60 bg-muted/20 px-3 py-2">
|
|
||||||
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Source</div>
|
|
||||||
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{skill.sourcePath}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
</section>
|
{skill.description || 'No description provided in the skill front matter.'}
|
||||||
))}
|
</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
{skill.pluginName && (
|
||||||
|
<Badge variant="outline" className="rounded-full bg-background/70">
|
||||||
|
Plugin: {skill.pluginName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{skill.projectDisplayName && (
|
||||||
|
<Badge variant="outline" className="rounded-full bg-background/70">
|
||||||
|
Project: {skill.projectDisplayName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 min-w-0 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Source</div>
|
||||||
|
<code className="mt-1 block whitespace-normal break-all text-xs text-foreground">{skill.sourcePath}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
157
src/shared/view/ui/ActionMenu.tsx
Normal file
157
src/shared/view/ui/ActionMenu.tsx
Normal file
@@ -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<HTMLDivElement | null>(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 (
|
||||||
|
<div ref={rootRef} className={cn('relative inline-flex', className)}>
|
||||||
|
<Button
|
||||||
|
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
|
||||||
|
id={menuId}
|
||||||
|
role="menu"
|
||||||
|
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