feat: Implement skill management functionality

- Added skill scope helpers and CRUD operations for skills in opencodeConfig.ts.
- Introduced API endpoints for skill management in main.tsx and index.js.
- Enhanced server-side logic to support skill discovery, creation, updating, and deletion.
- Implemented supporting file operations for skills, including reading, writing, and deleting files.
- Updated package.json to use the latest version of @opencode-ai/sdk.
This commit is contained in:
Bohdan Triapitsyn
2025-12-30 17:36:52 +02:00
parent f3a00bc8f4
commit 2c833cef40
25 changed files with 3587 additions and 15 deletions
@@ -1,7 +1,7 @@
import React from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
@@ -82,6 +82,9 @@ export const getToolIcon = (toolName: string) => {
if (tool === 'todowrite' || tool === 'todoread') {
return <RiListCheck3 className={iconClass} />;
}
if (tool === 'skill') {
return <RiBookLine className={iconClass} />;
}
if (tool.startsWith('git')) {
return <RiGitBranchLine className={iconClass} />;
}
@@ -171,6 +174,10 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile:
return isMobile ? input.description.substring(0, 40) : input.description.substring(0, 80);
}
if (part.tool === 'skill' && input?.name && typeof input.name === 'string') {
return input.name;
}
const desc = input?.description || metadata?.description || ('title' in state && state.title) || '';
return typeof desc === 'string' ? desc : '';
};
@@ -654,6 +661,14 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
);
}
if (part.tool === 'skill' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={outputString} variant="tool" />
</div>
);
}
if ((part.tool === 'edit' || part.tool === 'multiedit') && ((!hasStringOutput && diffContent) || (outputString.trim().length === 0 || hasLspDiagnostics(outputString))) && diffContent) {
return renderScrollableBlock(
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
@@ -0,0 +1,577 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiSaveLine, RiUser3Line } from '@remixicon/react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
export const SkillsPage: React.FC = () => {
const {
selectedSkillName,
getSkillByName,
getSkillDetail,
createSkill,
updateSkill,
skills,
skillDraft,
setSkillDraft,
setSelectedSkill,
} = useSkillsStore();
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
const [draftName, setDraftName] = React.useState('');
const [draftScope, setDraftScope] = React.useState<SkillScope>('user');
const [description, setDescription] = React.useState('');
const [instructions, setInstructions] = React.useState('');
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]); // For new skills
const [isSaving, setIsSaving] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
// Track original values to detect changes
const [originalDescription, setOriginalDescription] = React.useState('');
const [originalInstructions, setOriginalInstructions] = React.useState('');
// File dialog state
const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false);
const [newFileName, setNewFileName] = React.useState('');
const [newFileContent, setNewFileContent] = React.useState('');
const [editingFilePath, setEditingFilePath] = React.useState<string | null>(null); // null = adding, string = editing
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
const [originalFileContent, setOriginalFileContent] = React.useState(''); // Track original for change detection
// Detect if skill-level fields have changed
const hasSkillChanges = isNewSkill
? (draftName.trim() !== '' || description.trim() !== '' || instructions.trim() !== '' || pendingFiles.length > 0)
: (description !== originalDescription || instructions !== originalInstructions);
// Detect if file content has changed
const hasFileChanges = editingFilePath
? newFileContent !== originalFileContent
: newFileName.trim() !== ''; // For new files, just need a name
// Load skill details when selection changes
React.useEffect(() => {
const loadSkillDetails = async () => {
if (isNewSkill && skillDraft) {
// Prefill from draft (for new or duplicated skills)
setDraftName(skillDraft.name || '');
setDraftScope(skillDraft.scope || 'user');
setDescription(skillDraft.description || '');
setInstructions(skillDraft.instructions || '');
setOriginalDescription('');
setOriginalInstructions('');
setSupportingFiles([]);
setPendingFiles(skillDraft.pendingFiles || []);
} else if (selectedSkillName && selectedSkill) {
setIsLoading(true);
try {
const detail = await getSkillDetail(selectedSkillName);
if (detail) {
// Get actual content from the API response
const md = detail.sources.md;
setDescription(md.description || '');
setInstructions(md.instructions || '');
setOriginalDescription(md.description || '');
setOriginalInstructions(md.instructions || '');
setSupportingFiles(md.supportingFiles || []);
}
} catch (error) {
console.error('Failed to load skill details:', error);
} finally {
setIsLoading(false);
}
}
};
loadSkillDetails();
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
const handleSave = async () => {
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
if (!skillName) {
toast.error('Skill name is required');
return;
}
// Validate skill name format
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
toast.error('Skill name must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen');
return;
}
if (!description.trim()) {
toast.error('Description is required');
return;
}
// Check for duplicate name when creating new skill
if (isNewSkill && skills.some((s) => s.name === skillName)) {
toast.error('A skill with this name already exists');
return;
}
setIsSaving(true);
try {
const config: SkillConfig = {
name: skillName,
description: description.trim(),
instructions: instructions.trim() || undefined,
scope: isNewSkill ? draftScope : undefined,
// Include pending files when creating new skill
supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined,
};
let success: boolean;
if (isNewSkill) {
success = await createSkill(config);
if (success) {
setSkillDraft(null); // Clear draft after successful creation
setPendingFiles([]); // Clear pending files
setSelectedSkill(skillName); // Select the newly created skill
}
} else {
success = await updateSkill(skillName, config);
if (success) {
// Update original values to reflect saved state
setOriginalDescription(description.trim());
setOriginalInstructions(instructions.trim());
}
}
if (success) {
toast.success(isNewSkill ? 'Skill created successfully' : 'Skill updated successfully');
} else {
toast.error(isNewSkill ? 'Failed to create skill' : 'Failed to update skill');
}
} catch (error) {
console.error('Error saving skill:', error);
toast.error('An error occurred while saving');
} finally {
setIsSaving(false);
}
};
const handleAddFile = () => {
setEditingFilePath(null);
setNewFileName('');
setNewFileContent('');
setOriginalFileContent('');
setIsFileDialogOpen(true);
};
const handleEditFile = async (filePath: string) => {
setEditingFilePath(filePath);
setNewFileName(filePath);
// For new skills, get content from pending files
if (isNewSkill) {
const pendingFile = pendingFiles.find(f => f.path === filePath);
const content = pendingFile?.content || '';
setNewFileContent(content);
setOriginalFileContent(content);
setIsFileDialogOpen(true);
return;
}
// For existing skills, load content from server
if (!selectedSkillName) return;
setIsLoadingFile(true);
setIsFileDialogOpen(true);
try {
const { readSupportingFile } = useSkillsStore.getState();
const content = await readSupportingFile(selectedSkillName, filePath);
setNewFileContent(content || '');
setOriginalFileContent(content || '');
} catch {
toast.error('Failed to load file content');
setNewFileContent('');
setOriginalFileContent('');
} finally {
setIsLoadingFile(false);
}
};
const handleSaveFile = async () => {
if (!newFileName.trim()) {
toast.error('File name is required');
return;
}
const filePath = newFileName.trim();
const isEditing = editingFilePath !== null;
// For new skills, add/update pending files
if (isNewSkill) {
if (isEditing) {
// Update existing pending file
setPendingFiles(prev => prev.map(f =>
f.path === editingFilePath ? { path: filePath, content: newFileContent } : f
));
toast.success(`File "${filePath}" updated`);
} else {
// Check for duplicate
if (pendingFiles.some(f => f.path === filePath)) {
toast.error('A file with this name already exists');
return;
}
setPendingFiles(prev => [...prev, { path: filePath, content: newFileContent }]);
toast.success(`File "${filePath}" added`);
}
setIsFileDialogOpen(false);
setEditingFilePath(null);
return;
}
// For existing skills, write directly to disk
if (!selectedSkillName) {
toast.error('No skill selected');
return;
}
const { writeSupportingFile } = useSkillsStore.getState();
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent);
if (success) {
toast.success(isEditing ? `File "${filePath}" updated` : `File "${filePath}" created`);
setIsFileDialogOpen(false);
setEditingFilePath(null);
// Refresh skill details to get updated file list
const detail = await getSkillDetail(selectedSkillName);
if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []);
}
} else {
toast.error(isEditing ? 'Failed to update file' : 'Failed to create file');
}
};
const handleDeleteFile = async (filePath: string) => {
// For new skills, remove from pending files
if (isNewSkill) {
setPendingFiles(prev => prev.filter(f => f.path !== filePath));
toast.success(`File "${filePath}" removed`);
return;
}
// For existing skills, delete from disk
if (!selectedSkillName) return;
if (window.confirm(`Are you sure you want to delete "${filePath}"?`)) {
const { deleteSupportingFile } = useSkillsStore.getState();
const success = await deleteSupportingFile(selectedSkillName, filePath);
if (success) {
toast.success(`File "${filePath}" deleted`);
// Refresh skill details
const detail = await getSkillDetail(selectedSkillName);
if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []);
}
} else {
toast.error('Failed to delete file');
}
}
};
// Show empty state only when nothing is selected AND no draft
if (!selectedSkillName && !skillDraft) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiBookOpenLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">Select a skill from the sidebar</p>
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<p className="typography-body">Loading skill details...</p>
</div>
</div>
);
}
return (
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{isNewSkill ? 'New Skill' : selectedSkillName}
</h1>
{selectedSkill && (
<p className="typography-meta text-muted-foreground">
{selectedSkill.scope === 'project' ? 'Project' : 'User'} skill
{selectedSkill.source === 'claude' && ' (Claude-compatible)'}
</p>
)}
</div>
{/* Basic Information */}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2>
<p className="typography-meta text-muted-foreground/80">
Configure skill identity and description
</p>
</div>
{isNewSkill && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Skill Name & Scope
</label>
<div className="flex items-center gap-2">
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
placeholder="skill-name"
className="flex-1 text-foreground placeholder:text-muted-foreground"
/>
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as SkillScope)}>
<SelectTrigger className="!h-9 w-auto gap-1.5">
{draftScope === 'user' ? (
<RiUser3Line className="h-4 w-4" />
) : (
<RiFolderLine className="h-4 w-4" />
)}
<span className="capitalize">{draftScope}</span>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<RiUser3Line className="h-4 w-4" />
<span>User</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
</div>
</SelectItem>
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<RiFolderLine className="h-4 w-4" />
<span>Project</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<p className="typography-meta text-muted-foreground">
Lowercase letters, numbers, and hyphens only. Cannot start or end with hyphen.
</p>
</div>
)}
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Description <span className="text-destructive">*</span>
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does..."
rows={2}
/>
<p className="typography-meta text-muted-foreground">
The agent uses this to decide when to load the skill
</p>
</div>
</div>
{/* Instructions */}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Instructions</h2>
<p className="typography-meta text-muted-foreground/80">
Detailed instructions for the agent when this skill is loaded
</p>
</div>
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Step-by-step instructions, guidelines, or reference content..."
rows={12}
className="font-mono typography-meta"
/>
</div>
{/* Supporting Files */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Supporting Files</h2>
<p className="typography-meta text-muted-foreground/80">
Reference documentation, scripts, or templates
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddFile}
className="gap-1.5"
>
<RiAddLine className="h-3.5 w-3.5" />
Add File
</Button>
</div>
{(() => {
// For new skills, show pending files
const filesToShow = isNewSkill ? pendingFiles : supportingFiles;
if (filesToShow.length === 0) {
return (
<p className="typography-meta text-muted-foreground py-2">
{isNewSkill ? 'No files yet. Use "Add File" to include reference materials.' : 'No supporting files. Use "Add File" to include reference materials.'}
</p>
);
}
return (
<div className="space-y-2">
{filesToShow.map((file) => (
<div
key={file.path}
className="flex items-center justify-between px-3 py-2 rounded-lg border bg-muted/30 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => handleEditFile(file.path)}
>
<div className="flex items-center gap-2 min-w-0">
<RiFileLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="typography-ui-label truncate">{file.path}</span>
{isNewSkill && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
pending
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(file.path);
}}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
);
})()}
</div>
{/* Save Button */}
<div className="flex justify-end border-t border-border/40 pt-4">
<Button
size="sm"
variant="default"
onClick={handleSave}
disabled={isSaving || !hasSkillChanges}
className="gap-2 h-6 px-2 text-xs w-fit"
>
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
</Button>
</div>
{/* Add/Edit File Dialog */}
<Dialog open={isFileDialogOpen} onOpenChange={(open) => {
setIsFileDialogOpen(open);
if (!open) setEditingFilePath(null);
}}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
<DialogDescription>
{editingFilePath ? 'Modify the file content' : 'Create a new file in the skill directory'}
</DialogDescription>
</DialogHeader>
{isLoadingFile ? (
<div className="flex-1 flex items-center justify-center py-8">
<span className="typography-meta text-muted-foreground">Loading file content...</span>
</div>
) : (
<div className="space-y-4 flex-1 min-h-0 flex flex-col">
<div className="space-y-2 flex-shrink-0">
<label className="typography-ui-label font-medium text-foreground">
File Path
</label>
<Input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder="example.md or docs/reference.txt"
className="text-foreground placeholder:text-muted-foreground"
disabled={editingFilePath !== null}
/>
{!editingFilePath && (
<p className="typography-micro text-muted-foreground">
Relative path within the skill directory. Subdirectories will be created automatically.
</p>
)}
</div>
<div className="space-y-2 flex-1 min-h-0 flex flex-col">
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
Content
</label>
<Textarea
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
placeholder="File content..."
className="font-mono typography-meta flex-1 min-h-[200px] max-h-full resize-none"
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setIsFileDialogOpen(false);
setEditingFilePath(null);
}}
className="text-foreground hover:bg-muted hover:text-foreground"
>
Cancel
</Button>
<ButtonLarge onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
{editingFilePath ? 'Save Changes' : 'Create File'}
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</ScrollableOverlay>
);
};
@@ -0,0 +1,401 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiEditLine, RiBookOpenLine } from '@remixicon/react';
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface SkillsSidebarProps {
onItemSelect?: () => void;
}
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
const [renameDialogSkill, setRenameDialogSkill] = React.useState<DiscoveredSkill | null>(null);
const [renameNewName, setRenameNewName] = React.useState('');
const {
selectedSkillName,
skills,
setSelectedSkill,
setSkillDraft,
createSkill,
deleteSkill,
loadSkills,
getSkillDetail,
} = useSkillsStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
React.useEffect(() => {
loadSkills();
}, [loadSkills]);
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const handleCreateNew = () => {
// Generate unique name
const baseName = 'new-skill';
let newName = baseName;
let counter = 1;
while (skills.some((s) => s.name === newName)) {
newName = `${baseName}-${counter}`;
counter++;
}
// Set draft and open the page for editing
setSkillDraft({ name: newName, scope: 'user', description: '' });
setSelectedSkill(newName);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteSkill = async (skill: DiscoveredSkill) => {
if (window.confirm(`Are you sure you want to delete skill "${skill.name}"?`)) {
const success = await deleteSkill(skill.name);
if (success) {
toast.success(`Skill "${skill.name}" deleted successfully`);
} else {
toast.error('Failed to delete skill');
}
}
};
const handleDuplicateSkill = async (skill: DiscoveredSkill) => {
const baseName = skill.name;
let copyNumber = 1;
let newName = `${baseName}-copy`;
while (skills.some((s) => s.name === newName)) {
copyNumber++;
newName = `${baseName}-copy-${copyNumber}`;
}
// Get full skill detail to copy
const detail = await getSkillDetail(skill.name);
if (!detail) {
toast.error('Failed to load skill details for duplication');
return;
}
// Set draft with prefilled values from source skill
setSkillDraft({
name: newName,
scope: skill.scope || 'user',
description: detail.sources.md.fields.includes('description') ? '' : '', // Will be populated from page
instructions: '',
});
setSelectedSkill(newName);
if (isMobile) {
setSidebarOpen(false);
}
};
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
setRenameNewName(skill.name);
setRenameDialogSkill(skill);
};
const handleRenameSkill = async () => {
if (!renameDialogSkill) return;
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-').toLowerCase();
if (!sanitizedName) {
toast.error('Skill name is required');
return;
}
if (sanitizedName === renameDialogSkill.name) {
setRenameDialogSkill(null);
return;
}
if (skills.some((s) => s.name === sanitizedName)) {
toast.error('A skill with this name already exists');
return;
}
// Get full detail to copy
const detail = await getSkillDetail(renameDialogSkill.name);
if (!detail) {
toast.error('Failed to load skill details');
setRenameDialogSkill(null);
return;
}
// Create new skill with new name
const success = await createSkill({
name: sanitizedName,
description: 'Renamed skill', // Will need proper description
scope: renameDialogSkill.scope,
});
if (success) {
// Delete old skill
const deleteSuccess = await deleteSkill(renameDialogSkill.name);
if (deleteSuccess) {
toast.success(`Skill renamed to "${sanitizedName}"`);
setSelectedSkill(sanitizedName);
} else {
toast.error('Failed to remove old skill after rename');
}
} else {
toast.error('Failed to rename skill');
}
setRenameDialogSkill(null);
};
// Separate project and user skills
const projectSkills = skills.filter((s) => s.scope === 'project');
const userSkills = skills.filter((s) => s.scope === 'user');
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {skills.length}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<RiAddLine className="size-4" />
</Button>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{skills.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiBookOpenLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">No skills configured</p>
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
</div>
) : (
<>
{projectSkills.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Project Skills
</div>
{projectSkills.map((skill) => (
<SkillListItem
key={skill.name}
skill={skill}
isSelected={selectedSkillName === skill.name}
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
/>
))}
</>
)}
{userSkills.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
User Skills
</div>
{userSkills.map((skill) => (
<SkillListItem
key={skill.name}
skill={skill}
isSelected={selectedSkillName === skill.name}
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
/>
))}
</>
)}
</>
)}
</ScrollableOverlay>
{/* Rename Dialog */}
<Dialog open={renameDialogSkill !== null} onOpenChange={(open) => !open && setRenameDialogSkill(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Skill</DialogTitle>
<DialogDescription>
Enter a new name for the skill "{renameDialogSkill?.name}"
</DialogDescription>
</DialogHeader>
<Input
value={renameNewName}
onChange={(e) => setRenameNewName(e.target.value)}
placeholder="New skill name..."
className="text-foreground placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameSkill();
}
}}
/>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setRenameDialogSkill(null)}
className="text-foreground hover:bg-muted hover:text-foreground"
>
Cancel
</Button>
<ButtonLarge onClick={handleRenameSkill}>
Rename
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
interface SkillListItemProps {
skill: DiscoveredSkill;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
onRename: () => void;
onDuplicate: () => void;
}
const SkillListItem: React.FC<SkillListItemProps> = ({
skill,
isSelected,
onSelect,
onDelete,
onRename,
onDuplicate,
}) => {
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex items-center gap-1.5">
<span className="typography-ui-label font-normal truncate text-foreground">
{skill.name}
</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{skill.scope}
</span>
{skill.source === 'claude' && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
claude
</span>
)}
</div>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<RiEditLine className="h-4 w-4 mr-px" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<RiFileCopyLine className="h-4 w-4 mr-px" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
};
@@ -0,0 +1,2 @@
export { SkillsSidebar } from './SkillsSidebar';
export { SkillsPage } from './SkillsPage';
@@ -8,6 +8,8 @@ import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
import { AgentsPage } from '@/components/sections/agents/AgentsPage';
import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar';
import { CommandsPage } from '@/components/sections/commands/CommandsPage';
import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
@@ -209,6 +211,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <AgentsSidebar onItemSelect={handleMobileSidebarClick} />;
case 'commands':
return <CommandsSidebar onItemSelect={handleMobileSidebarClick} />;
case 'skills':
return <SkillsSidebar onItemSelect={handleMobileSidebarClick} />;
case 'providers':
return <ProvidersSidebar onItemSelect={handleMobileSidebarClick} />;
case 'git-identities':
@@ -226,6 +230,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <AgentsPage />;
case 'commands':
return <CommandsPage />;
case 'skills':
return <SkillsPage />;
case 'providers':
return <ProvidersPage />;
case 'git-identities':