|
|
|
|
@@ -2,7 +2,7 @@
|
|
|
|
|
* Skills Page
|
|
|
|
|
* Browse and manage AI skills
|
|
|
|
|
*/
|
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
Search,
|
|
|
|
|
Puzzle,
|
|
|
|
|
@@ -346,25 +346,21 @@ export function Skills() {
|
|
|
|
|
const { t } = useTranslation('skills');
|
|
|
|
|
const gatewayStatus = useGatewayStore((state) => state.status);
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
const [marketplaceQuery, setMarketplaceQuery] = useState('');
|
|
|
|
|
const [installQuery, setInstallQuery] = useState('');
|
|
|
|
|
const [installSheetOpen, setInstallSheetOpen] = useState(false);
|
|
|
|
|
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
|
|
|
|
const [activeTab, setActiveTab] = useState('all');
|
|
|
|
|
const [selectedSource, setSelectedSource] = useState<'all' | 'built-in' | 'marketplace'>('all');
|
|
|
|
|
const marketplaceDiscoveryAttemptedRef = useRef(false);
|
|
|
|
|
|
|
|
|
|
const isGatewayRunning = gatewayStatus.state === 'running';
|
|
|
|
|
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Debounce the gateway warning to avoid flickering during brief restarts (like skill toggles)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let timer: NodeJS.Timeout;
|
|
|
|
|
if (!isGatewayRunning) {
|
|
|
|
|
// Wait 1.5s before showing the warning
|
|
|
|
|
timer = setTimeout(() => {
|
|
|
|
|
setShowGatewayWarning(true);
|
|
|
|
|
}, 1500);
|
|
|
|
|
} else {
|
|
|
|
|
// Use setTimeout to avoid synchronous setState in effect
|
|
|
|
|
timer = setTimeout(() => {
|
|
|
|
|
setShowGatewayWarning(false);
|
|
|
|
|
}, 0);
|
|
|
|
|
@@ -372,14 +368,12 @@ export function Skills() {
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}, [isGatewayRunning]);
|
|
|
|
|
|
|
|
|
|
// Fetch skills on mount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isGatewayRunning) {
|
|
|
|
|
fetchSkills();
|
|
|
|
|
}
|
|
|
|
|
}, [fetchSkills, isGatewayRunning]);
|
|
|
|
|
|
|
|
|
|
// Filter skills
|
|
|
|
|
const safeSkills = Array.isArray(skills) ? skills : [];
|
|
|
|
|
const filteredSkills = safeSkills.filter((skill) => {
|
|
|
|
|
const q = searchQuery.toLowerCase().trim();
|
|
|
|
|
@@ -400,13 +394,10 @@ export function Skills() {
|
|
|
|
|
|
|
|
|
|
return matchesSearch && matchesSource;
|
|
|
|
|
}).sort((a, b) => {
|
|
|
|
|
// Enabled skills first
|
|
|
|
|
if (a.enabled && !b.enabled) return -1;
|
|
|
|
|
if (!a.enabled && b.enabled) return 1;
|
|
|
|
|
// Then core/bundled
|
|
|
|
|
if (a.isCore && !b.isCore) return -1;
|
|
|
|
|
if (!a.isCore && b.isCore) return 1;
|
|
|
|
|
// Finally alphabetical
|
|
|
|
|
return a.name.localeCompare(b.name);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@@ -445,7 +436,6 @@ export function Skills() {
|
|
|
|
|
toast.warning(t('toast.batchPartial', { success: succeeded, total: candidates.length }));
|
|
|
|
|
}, [disableSkill, enableSkill, filteredSkills, t]);
|
|
|
|
|
|
|
|
|
|
// Handle toggle
|
|
|
|
|
const handleToggle = useCallback(async (skillId: string, enable: boolean) => {
|
|
|
|
|
try {
|
|
|
|
|
if (enable) {
|
|
|
|
|
@@ -470,7 +460,6 @@ export function Skills() {
|
|
|
|
|
}
|
|
|
|
|
const result = await invokeIpc<string>('shell:openPath', skillsDir);
|
|
|
|
|
if (result) {
|
|
|
|
|
// shell.openPath returns an error string if the path doesn't exist
|
|
|
|
|
if (result.toLowerCase().includes('no such file') || result.toLowerCase().includes('not found') || result.toLowerCase().includes('failed to open')) {
|
|
|
|
|
toast.error(t('toast.failedFolderNotFound'));
|
|
|
|
|
} else {
|
|
|
|
|
@@ -490,20 +479,26 @@ export function Skills() {
|
|
|
|
|
.catch(console.error);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Auto-reset when query is cleared
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (activeTab === 'marketplace' && marketplaceQuery === '' && marketplaceDiscoveryAttemptedRef.current) {
|
|
|
|
|
searchSkills('');
|
|
|
|
|
if (!installSheetOpen) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}, [marketplaceQuery, activeTab, searchSkills]);
|
|
|
|
|
|
|
|
|
|
// Handle install
|
|
|
|
|
const query = installQuery.trim();
|
|
|
|
|
if (query.length === 0) {
|
|
|
|
|
searchSkills('');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
searchSkills(query);
|
|
|
|
|
}, 300);
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}, [installQuery, installSheetOpen, searchSkills]);
|
|
|
|
|
|
|
|
|
|
const handleInstall = useCallback(async (slug: string) => {
|
|
|
|
|
try {
|
|
|
|
|
await installSkill(slug);
|
|
|
|
|
// Automatically enable after install
|
|
|
|
|
// We need to find the skill id which is usually the slug
|
|
|
|
|
await enableSkill(slug);
|
|
|
|
|
toast.success(t('toast.installed'));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
@@ -516,25 +511,6 @@ export function Skills() {
|
|
|
|
|
}
|
|
|
|
|
}, [installSkill, enableSkill, t, skillsDirPath]);
|
|
|
|
|
|
|
|
|
|
// Initial marketplace load (Discovery)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (activeTab !== 'marketplace') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (marketplaceQuery.trim()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (searching) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (marketplaceDiscoveryAttemptedRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
marketplaceDiscoveryAttemptedRef.current = true;
|
|
|
|
|
searchSkills('');
|
|
|
|
|
}, [activeTab, marketplaceQuery, searching, searchSkills]);
|
|
|
|
|
|
|
|
|
|
// Handle uninstall
|
|
|
|
|
const handleUninstall = useCallback(async (slug: string) => {
|
|
|
|
|
try {
|
|
|
|
|
await uninstallSkill(slug);
|
|
|
|
|
@@ -597,14 +573,14 @@ export function Skills() {
|
|
|
|
|
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
|
|
|
<input
|
|
|
|
|
placeholder={t('search')}
|
|
|
|
|
value={activeTab === 'marketplace' ? marketplaceQuery : searchQuery}
|
|
|
|
|
onChange={(e) => activeTab === 'marketplace' ? setMarketplaceQuery(e.target.value) : setSearchQuery(e.target.value)}
|
|
|
|
|
className="ml-2 bg-transparent outline-none w-24 focus:w-40 md:focus:w-56 transition-all font-normal placeholder:text-foreground/50 text-[13px] text-foreground"
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
className="ml-2 bg-transparent outline-none w-28 md:w-40 font-normal placeholder:text-foreground/50 text-[13px] text-foreground"
|
|
|
|
|
/>
|
|
|
|
|
{((activeTab === 'marketplace' && marketplaceQuery) || (activeTab === 'all' && searchQuery)) && (
|
|
|
|
|
{searchQuery && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => activeTab === 'marketplace' ? setMarketplaceQuery('') : setSearchQuery('')}
|
|
|
|
|
onClick={() => setSearchQuery('')}
|
|
|
|
|
className="text-foreground/50 hover:text-foreground shrink-0 ml-1"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
@@ -614,20 +590,20 @@ export function Skills() {
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-6">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => { setActiveTab('all'); setSelectedSource('all'); }}
|
|
|
|
|
className={cn("font-medium transition-colors flex items-center gap-1.5", activeTab === 'all' && selectedSource === 'all' ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
|
|
|
|
|
onClick={() => setSelectedSource('all')}
|
|
|
|
|
className={cn("font-medium transition-colors flex items-center gap-1.5", selectedSource === 'all' ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
|
|
|
|
|
>
|
|
|
|
|
{t('filter.all', { count: sourceStats.all })}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => { setActiveTab('all'); setSelectedSource('built-in'); }}
|
|
|
|
|
className={cn("font-medium transition-colors flex items-center gap-1.5", activeTab === 'all' && selectedSource === 'built-in' ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
|
|
|
|
|
onClick={() => setSelectedSource('built-in')}
|
|
|
|
|
className={cn("font-medium transition-colors flex items-center gap-1.5", selectedSource === 'built-in' ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
|
|
|
|
|
>
|
|
|
|
|
{t('filter.builtIn', { count: sourceStats.builtIn })}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setActiveTab('marketplace')}
|
|
|
|
|
className={cn("font-medium transition-colors flex items-center gap-1.5", activeTab === 'marketplace' ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
|
|
|
|
|
onClick={() => setSelectedSource('marketplace')}
|
|
|
|
|
className={cn("font-medium transition-colors flex items-center gap-1.5", selectedSource === 'marketplace' ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
|
|
|
|
|
>
|
|
|
|
|
{t('filter.marketplace', { count: sourceStats.marketplace })}
|
|
|
|
|
</button>
|
|
|
|
|
@@ -635,8 +611,6 @@ export function Skills() {
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
|
|
|
{activeTab === 'all' && (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
@@ -653,15 +627,24 @@ export function Skills() {
|
|
|
|
|
>
|
|
|
|
|
{t('actions.disableVisible')}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setInstallQuery('');
|
|
|
|
|
setInstallSheetOpen(true);
|
|
|
|
|
}}
|
|
|
|
|
className="h-8 text-[13px] font-medium rounded-md px-3 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none"
|
|
|
|
|
>
|
|
|
|
|
{t('actions.installSkill')}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={fetchSkills}
|
|
|
|
|
disabled={!isGatewayRunning}
|
|
|
|
|
className="h-8 w-8 ml-1 rounded-md border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-muted-foreground hover:text-foreground"
|
|
|
|
|
title="Refresh"
|
|
|
|
|
title={t('refresh')}
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
|
|
|
|
</Button>
|
|
|
|
|
@@ -670,7 +653,7 @@ export function Skills() {
|
|
|
|
|
|
|
|
|
|
{/* Content Area */}
|
|
|
|
|
<div className="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 -mr-2">
|
|
|
|
|
{error && activeTab === 'all' && (
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="mb-4 p-4 rounded-xl border border-destructive/50 bg-destructive/10 text-destructive text-sm font-medium flex items-center gap-2">
|
|
|
|
|
<AlertCircle className="h-5 w-5 shrink-0" />
|
|
|
|
|
<span>
|
|
|
|
|
@@ -682,8 +665,7 @@ export function Skills() {
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
{activeTab === 'all' && (
|
|
|
|
|
filteredSkills.length === 0 ? (
|
|
|
|
|
{filteredSkills.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
|
|
|
|
<Puzzle className="h-10 w-10 mb-4 opacity-50" />
|
|
|
|
|
<p>{searchQuery ? t('noSkillsSearch') : t('noSkillsAvailable')}</p>
|
|
|
|
|
@@ -732,11 +714,49 @@ export function Skills() {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{activeTab === 'marketplace' && (
|
|
|
|
|
<div className="flex flex-col gap-1 mt-2">
|
|
|
|
|
<Sheet open={installSheetOpen} onOpenChange={setInstallSheetOpen}>
|
|
|
|
|
<SheetContent
|
|
|
|
|
className="w-full sm:max-w-[560px] p-0 flex flex-col border-l border-black/10 dark:border-white/10 bg-[#f3f1e9] dark:bg-card shadow-[0_0_40px_rgba(0,0,0,0.2)]"
|
|
|
|
|
side="right"
|
|
|
|
|
>
|
|
|
|
|
<div className="px-7 py-6 border-b border-black/10 dark:border-white/10">
|
|
|
|
|
<h2 className="text-[24px] font-serif text-foreground font-normal tracking-tight">{t('marketplace.installDialogTitle')}</h2>
|
|
|
|
|
<p className="mt-1 text-[13px] text-foreground/70">{t('marketplace.installDialogSubtitle')}</p>
|
|
|
|
|
<div className="mt-4 flex flex-col md:flex-row gap-2">
|
|
|
|
|
<div className="relative flex items-center bg-black/5 dark:bg-white/5 rounded-xl px-3 py-2 border border-black/10 dark:border-white/10 flex-1">
|
|
|
|
|
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder={t('searchMarketplace')}
|
|
|
|
|
value={installQuery}
|
|
|
|
|
onChange={(e) => setInstallQuery(e.target.value)}
|
|
|
|
|
className="ml-2 h-auto border-0 bg-transparent p-0 shadow-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 text-[13px]"
|
|
|
|
|
/>
|
|
|
|
|
{installQuery && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setInstallQuery('')}
|
|
|
|
|
className="text-foreground/50 hover:text-foreground shrink-0 ml-1"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
disabled
|
|
|
|
|
className="h-10 rounded-xl border-black/10 dark:border-white/10 bg-transparent text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
{t('marketplace.sourceLabel')}: {t('marketplace.sourceClawHub')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
|
|
|
{searchError && (
|
|
|
|
|
<div className="mb-4 p-4 rounded-xl border border-destructive/50 bg-destructive/10 text-destructive text-sm font-medium flex items-center gap-2">
|
|
|
|
|
<AlertCircle className="h-5 w-5 shrink-0" />
|
|
|
|
|
@@ -748,15 +768,16 @@ export function Skills() {
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{activeTab === 'marketplace' && marketplaceQuery && searching && (
|
|
|
|
|
{searching && (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
|
|
|
|
<LoadingSpinner size="lg" />
|
|
|
|
|
<p className="mt-4 text-sm">{t('marketplace.searching')}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{searchResults.length > 0 ? (
|
|
|
|
|
searchResults.map((skill) => {
|
|
|
|
|
{!searching && searchResults.length > 0 && (
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
{searchResults.map((skill) => {
|
|
|
|
|
const isInstalled = safeSkills.some(s => s.id === skill.slug || s.name === skill.name);
|
|
|
|
|
const isInstallLoading = !!installing[skill.slug];
|
|
|
|
|
|
|
|
|
|
@@ -812,20 +833,19 @@ export function Skills() {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
) : (
|
|
|
|
|
!searching && marketplaceQuery && (
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!searching && searchResults.length === 0 && !searchError && (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
|
|
|
|
<Package className="h-10 w-10 mb-4 opacity-50" />
|
|
|
|
|
<p>{t('marketplace.noResults')}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
<p>{installQuery.trim() ? t('marketplace.noResults') : t('marketplace.emptyPrompt')}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</SheetContent>
|
|
|
|
|
</Sheet>
|
|
|
|
|
|
|
|
|
|
{/* Skill Detail Dialog */}
|
|
|
|
|
<SkillDetailDialog
|
|
|
|
|
|