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:
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user