feat(skills): align discovery with Opencode API and improve skills editor UX (#441)
This commit is contained in:
committed by
GitHub
parent
7eba5141fa
commit
14737b6b28
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
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 { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiSaveLine, RiUser3Line } from '@remixicon/react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import {
|
||||
Select,
|
||||
@@ -23,8 +25,23 @@ import {
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
|
||||
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from './skillLocations';
|
||||
|
||||
const LazyCodeMirrorEditor = React.lazy(async () => {
|
||||
const module = await import('@/components/ui/CodeMirrorEditor');
|
||||
return { default: module.CodeMirrorEditor };
|
||||
});
|
||||
|
||||
export const SkillsPage: React.FC = () => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const {
|
||||
selectedSkillName,
|
||||
getSkillByName,
|
||||
@@ -73,6 +90,7 @@ export const SkillsPage: React.FC = () => {
|
||||
|
||||
const [draftName, setDraftName] = React.useState('');
|
||||
const [draftScope, setDraftScope] = React.useState<SkillScope>('user');
|
||||
const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
const [description, setDescription] = React.useState('');
|
||||
const [instructions, setInstructions] = React.useState('');
|
||||
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
|
||||
@@ -93,6 +111,82 @@ export const SkillsPage: React.FC = () => {
|
||||
const [originalFileContent, setOriginalFileContent] = React.useState(''); // Track original for change detection
|
||||
const [deleteFilePath, setDeleteFilePath] = React.useState<string | null>(null);
|
||||
const [isDeletingFile, setIsDeletingFile] = React.useState(false);
|
||||
const [instructionsEditorHeight, setInstructionsEditorHeight] = React.useState(320);
|
||||
const [instructionsLanguage, setInstructionsLanguage] = React.useState<Extension | null>(null);
|
||||
const [supportingFileLanguage, setSupportingFileLanguage] = React.useState<Extension | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadInstructionsLanguage = async () => {
|
||||
const { languageByExtension } = await import('@/lib/codemirror/languageByExtension');
|
||||
if (cancelled) return;
|
||||
setInstructionsLanguage(languageByExtension('SKILL.md'));
|
||||
};
|
||||
|
||||
void loadInstructionsLanguage();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadSupportingFileLanguage = async () => {
|
||||
const targetPath = newFileName.trim();
|
||||
if (!targetPath) {
|
||||
setSupportingFileLanguage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { languageByExtension } = await import('@/lib/codemirror/languageByExtension');
|
||||
if (cancelled) return;
|
||||
setSupportingFileLanguage(languageByExtension(targetPath));
|
||||
};
|
||||
|
||||
void loadSupportingFileLanguage();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [newFileName]);
|
||||
|
||||
const instructionsEditorExtensions = React.useMemo(() => {
|
||||
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme), EditorView.lineWrapping];
|
||||
if (instructionsLanguage) {
|
||||
extensions.push(instructionsLanguage);
|
||||
}
|
||||
return extensions;
|
||||
}, [currentTheme, instructionsLanguage]);
|
||||
|
||||
const supportingFileEditorExtensions = React.useMemo(() => {
|
||||
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme), EditorView.lineWrapping];
|
||||
if (supportingFileLanguage) {
|
||||
extensions.push(supportingFileLanguage);
|
||||
}
|
||||
return extensions;
|
||||
}, [currentTheme, supportingFileLanguage]);
|
||||
|
||||
const handleStartInstructionsResize = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startY = event.clientY;
|
||||
const startHeight = instructionsEditorHeight;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaY = moveEvent.clientY - startY;
|
||||
const viewportMax = typeof window !== 'undefined' ? Math.floor(window.innerHeight * 0.75) : 800;
|
||||
const nextHeight = Math.max(220, Math.min(viewportMax, startHeight + deltaY));
|
||||
setInstructionsEditorHeight(nextHeight);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}, [instructionsEditorHeight]);
|
||||
|
||||
// Detect if skill-level fields have changed
|
||||
const hasSkillChanges = isNewSkill
|
||||
@@ -115,6 +209,7 @@ export const SkillsPage: React.FC = () => {
|
||||
// Prefill from draft (for new or duplicated skills)
|
||||
setDraftName(skillDraft.name || '');
|
||||
setDraftScope(skillDraft.scope || 'user');
|
||||
setDraftSource(skillDraft.source === 'agents' ? 'agents' : 'opencode');
|
||||
setDescription(skillDraft.description || '');
|
||||
setInstructions(skillDraft.instructions || '');
|
||||
setOriginalDescription('');
|
||||
@@ -178,6 +273,7 @@ export const SkillsPage: React.FC = () => {
|
||||
description: description.trim(),
|
||||
instructions: instructions.trim() || undefined,
|
||||
scope: isNewSkill ? draftScope : undefined,
|
||||
source: isNewSkill ? draftSource : undefined,
|
||||
// Include pending files when creating new skill
|
||||
supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
};
|
||||
@@ -387,7 +483,7 @@ export const SkillsPage: React.FC = () => {
|
||||
</h1>
|
||||
{selectedSkill && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{selectedSkill.scope === 'project' ? 'Project' : 'User'} skill
|
||||
{locationLabel(selectedSkill.scope, selectedSkill.source)} skill
|
||||
{selectedSkill.source === 'claude' && ' (Claude-compatible)'}
|
||||
</p>
|
||||
)}
|
||||
@@ -405,7 +501,7 @@ export const SkillsPage: React.FC = () => {
|
||||
{isNewSkill && (
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Skill Name & Scope
|
||||
Skill Name & Location
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
@@ -414,34 +510,36 @@ export const SkillsPage: React.FC = () => {
|
||||
placeholder="skill-name"
|
||||
className="flex-1 text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as SkillScope)}>
|
||||
<Select
|
||||
value={locationValueFrom(draftScope, draftSource)}
|
||||
onValueChange={(v) => {
|
||||
const next = locationPartsFrom(v as SkillLocationValue);
|
||||
setDraftScope(next.scope);
|
||||
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{draftSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{locationLabel(draftScope, draftSource)}</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>
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</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>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -476,13 +574,43 @@ export const SkillsPage: React.FC = () => {
|
||||
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 max-h-80 resize-y"
|
||||
/>
|
||||
<div
|
||||
className="relative min-h-[220px] max-h-[75vh] rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] overflow-hidden flex flex-col"
|
||||
style={{ height: `${instructionsEditorHeight}px` }}
|
||||
>
|
||||
<div className="flex-1 min-h-0">
|
||||
<React.Suspense
|
||||
fallback={(
|
||||
<Textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Step-by-step instructions, guidelines, or reference content..."
|
||||
rows={12}
|
||||
className="h-full border-0 rounded-none font-mono typography-meta resize-none"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LazyCodeMirrorEditor
|
||||
value={instructions}
|
||||
onChange={setInstructions}
|
||||
extensions={instructionsEditorExtensions}
|
||||
className="h-full"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize instructions editor"
|
||||
onMouseDown={handleStartInstructionsResize}
|
||||
className="absolute right-1.5 bottom-1.5 z-10 h-4 w-4 cursor-nwse-resize opacity-80 hover:opacity-100"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="h-4 w-4 text-[var(--surface-muted-foreground)]" aria-hidden="true">
|
||||
<path d="M6 14L14 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M10 14L14 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M2 14L14 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supporting Files */}
|
||||
@@ -636,12 +764,25 @@ export const SkillsPage: React.FC = () => {
|
||||
<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 className="h-[45vh] min-h-[220px] max-h-[55vh] rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] overflow-hidden">
|
||||
<React.Suspense
|
||||
fallback={(
|
||||
<Textarea
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
placeholder="File content..."
|
||||
className="h-full border-0 rounded-none font-mono typography-meta resize-none"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LazyCodeMirrorEditor
|
||||
value={newFileContent}
|
||||
onChange={setNewFileContent}
|
||||
extensions={supportingFileEditorExtensions}
|
||||
className="h-full"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -68,7 +68,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
// Set draft and open the page for editing
|
||||
setSkillDraft({ name: newName, scope: 'user', description: '' });
|
||||
setSkillDraft({ name: newName, scope: 'user', source: 'opencode', description: '' });
|
||||
setSelectedSkill(newName);
|
||||
onItemSelect?.();
|
||||
|
||||
@@ -115,12 +115,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
// 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: '',
|
||||
});
|
||||
setSkillDraft({
|
||||
name: newName,
|
||||
scope: skill.scope || 'user',
|
||||
source: skill.source || 'opencode',
|
||||
description: detail.sources.md.fields.includes('description') ? '' : '', // Will be populated from page
|
||||
instructions: '',
|
||||
});
|
||||
setSelectedSkill(newName);
|
||||
|
||||
if (isMobile) {
|
||||
@@ -166,6 +167,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
name: sanitizedName,
|
||||
description: 'Renamed skill', // Will need proper description
|
||||
scope: renameDialogSkill.scope,
|
||||
source: renameDialogSkill.source,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
@@ -378,6 +380,11 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
claude
|
||||
</span>
|
||||
)}
|
||||
{skill.source === 'agents' && (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
agents
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
export type SkillConflict = {
|
||||
skillName: string;
|
||||
scope: 'user' | 'project';
|
||||
source?: 'opencode' | 'agents';
|
||||
};
|
||||
|
||||
export type ConflictDecision = 'skip' | 'overwrite';
|
||||
@@ -85,7 +86,9 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label truncate">{conflict.skillName}</div>
|
||||
<div className="typography-micro text-muted-foreground">Installed in {conflict.scope} scope</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Installed in {conflict.scope} / {conflict.source || 'opencode'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { RiFolderLine, RiGitRepositoryLine, RiUser3Line } from '@remixicon/react';
|
||||
import { RiFolderLine, RiGitRepositoryLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
|
||||
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
@@ -28,6 +28,13 @@ import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from '../skillLocations';
|
||||
|
||||
interface InstallFromRepoDialogProps {
|
||||
open: boolean;
|
||||
@@ -45,6 +52,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
const [source, setSource] = React.useState('');
|
||||
const [subpath, setSubpath] = React.useState('');
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
|
||||
const [items, setItems] = React.useState<SkillsCatalogItem[]>([]);
|
||||
const [selected, setSelected] = React.useState<Record<string, boolean>>({});
|
||||
@@ -59,6 +67,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
targetSource: 'opencode' | 'agents';
|
||||
selections: Array<{ skillDir: string }>;
|
||||
gitIdentityId?: string;
|
||||
} | null>(null);
|
||||
@@ -68,6 +77,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
setSource('');
|
||||
setSubpath('');
|
||||
setScope('user');
|
||||
setTargetSource('opencode');
|
||||
setItems([]);
|
||||
setSelected({});
|
||||
setSearch('');
|
||||
@@ -82,9 +92,9 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
}, [open, loadDefaultGitIdentityId]);
|
||||
|
||||
const installedByName = React.useMemo(() => {
|
||||
const map = new Map<string, { scope: 'user' | 'project' }>();
|
||||
const map = new Map<string, { scope: 'user' | 'project'; source: 'opencode' | 'claude' | 'agents' }>();
|
||||
for (const s of installedSkills) {
|
||||
map.set(s.name, { scope: s.scope });
|
||||
map.set(s.name, { scope: s.scope, source: s.source });
|
||||
}
|
||||
return map;
|
||||
}, [installedSkills]);
|
||||
@@ -176,6 +186,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
source: source.trim(),
|
||||
subpath: subpath.trim() || undefined,
|
||||
scope,
|
||||
targetSource,
|
||||
selections: selectedDirs.map((dir) => ({ skillDir: dir })),
|
||||
gitIdentityId: gitIdentityId || undefined,
|
||||
};
|
||||
@@ -272,31 +283,33 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Target scope</label>
|
||||
<Select value={scope} onValueChange={(v) => setScope(v as 'user' | 'project')}>
|
||||
<label className="typography-ui-label font-medium text-foreground">Target location</label>
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
const next = locationPartsFrom(v as SkillLocationValue);
|
||||
setScope(next.scope);
|
||||
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="!h-9 w-full gap-1.5">
|
||||
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
<span className="capitalize">{scope}</span>
|
||||
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{locationLabel(scope, targetSource)}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<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>
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</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>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -378,7 +391,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
installed ({installed.scope})
|
||||
installed ({installed.scope}/{installed.source})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,18 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { RiFolderLine, RiUser3Line } from '@remixicon/react';
|
||||
import { RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
|
||||
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from '../skillLocations';
|
||||
|
||||
interface InstallSkillDialogProps {
|
||||
open: boolean;
|
||||
@@ -31,18 +38,21 @@ interface InstallSkillDialogProps {
|
||||
export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, onOpenChange, item }) => {
|
||||
const { installSkills, isInstalling } = useSkillsCatalogStore();
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
const [conflictsOpen, setConflictsOpen] = React.useState(false);
|
||||
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
|
||||
const [baseRequest, setBaseRequest] = React.useState<{
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
targetSource: 'opencode' | 'agents';
|
||||
skillDir: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setScope('user');
|
||||
setTargetSource('opencode');
|
||||
setConflictsOpen(false);
|
||||
setConflicts([]);
|
||||
setBaseRequest(null);
|
||||
@@ -52,6 +62,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
targetSource: 'opencode' | 'agents';
|
||||
skillDir: string;
|
||||
conflictDecisions?: Record<string, ConflictDecision>;
|
||||
}) => {
|
||||
@@ -71,6 +82,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
subpath: request.subpath,
|
||||
gitIdentityId: item?.gitIdentityId,
|
||||
scope: request.scope,
|
||||
targetSource: request.targetSource,
|
||||
selections: [selection],
|
||||
conflictPolicy: 'prompt',
|
||||
conflictDecisions: request.conflictDecisions,
|
||||
@@ -83,7 +95,13 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'conflicts') {
|
||||
setBaseRequest({ source: request.source, subpath: request.subpath, scope: request.scope, skillDir: request.skillDir });
|
||||
setBaseRequest({
|
||||
source: request.source,
|
||||
subpath: request.subpath,
|
||||
scope: request.scope,
|
||||
targetSource: request.targetSource,
|
||||
skillDir: request.skillDir,
|
||||
});
|
||||
setConflicts(result.error.conflicts);
|
||||
setConflictsOpen(true);
|
||||
return;
|
||||
@@ -108,7 +126,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Install <span className="font-semibold text-foreground">{item.skillName}</span> into user or project scope.
|
||||
Install <span className="font-semibold text-foreground">{item.skillName}</span> into one of four target locations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -127,26 +145,34 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
|
||||
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={scope} onValueChange={(v) => setScope(v as 'user' | 'project')}>
|
||||
<SelectTrigger className="!h-9 w-full sm:w-36 justify-between">
|
||||
<span className="flex flex-1 items-center gap-2 justify-start">
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
const next = locationPartsFrom(v as SkillLocationValue);
|
||||
setScope(next.scope);
|
||||
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="!h-9 w-full sm:w-auto">
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
<span className="capitalize">{scope}</span>
|
||||
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{locationLabel(scope, targetSource)}</span>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiUser3Line className="h-4 w-4" />
|
||||
<span>User</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
<span>Project</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -164,6 +190,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
source: item.repoSource,
|
||||
subpath: item.repoSubpath,
|
||||
scope,
|
||||
targetSource,
|
||||
skillDir: item.skillDir,
|
||||
})
|
||||
}
|
||||
@@ -185,6 +212,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
source: baseRequest.source,
|
||||
subpath: baseRequest.subpath,
|
||||
scope: baseRequest.scope,
|
||||
targetSource: baseRequest.targetSource,
|
||||
skillDir: baseRequest.skillDir,
|
||||
conflictDecisions: decisions,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { SkillScope, SkillSource } from '@/stores/useSkillsStore';
|
||||
|
||||
export type SkillLocationValue = 'user-opencode' | 'project-opencode' | 'user-agents' | 'project-agents';
|
||||
|
||||
export const SKILL_LOCATION_OPTIONS: Array<{
|
||||
value: SkillLocationValue;
|
||||
scope: SkillScope;
|
||||
source: SkillSource;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'user-opencode',
|
||||
scope: 'user',
|
||||
source: 'opencode',
|
||||
label: 'User / OpenCode',
|
||||
description: 'Global OpenCode config location',
|
||||
},
|
||||
{
|
||||
value: 'project-opencode',
|
||||
scope: 'project',
|
||||
source: 'opencode',
|
||||
label: 'Project / OpenCode',
|
||||
description: 'Current project .opencode location',
|
||||
},
|
||||
{
|
||||
value: 'user-agents',
|
||||
scope: 'user',
|
||||
source: 'agents',
|
||||
label: 'User / Agents',
|
||||
description: 'Global .agents compatibility location',
|
||||
},
|
||||
{
|
||||
value: 'project-agents',
|
||||
scope: 'project',
|
||||
source: 'agents',
|
||||
label: 'Project / Agents',
|
||||
description: 'Current project .agents compatibility location',
|
||||
},
|
||||
];
|
||||
|
||||
export function locationValueFrom(scope: SkillScope, source: SkillSource): SkillLocationValue {
|
||||
if (scope === 'project' && source === 'agents') return 'project-agents';
|
||||
if (scope === 'project') return 'project-opencode';
|
||||
if (source === 'agents') return 'user-agents';
|
||||
return 'user-opencode';
|
||||
}
|
||||
|
||||
export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScope; source: SkillSource } {
|
||||
const match = SKILL_LOCATION_OPTIONS.find((option) => option.value === value);
|
||||
if (!match) {
|
||||
return { scope: 'user', source: 'opencode' };
|
||||
}
|
||||
return { scope: match.scope, source: match.source };
|
||||
}
|
||||
|
||||
export function locationLabel(scope: SkillScope, source: SkillSource): string {
|
||||
const match = SKILL_LOCATION_OPTIONS.find((option) => option.scope === scope && option.source === source);
|
||||
return match?.label || `${scope} / ${source}`;
|
||||
}
|
||||
@@ -940,6 +940,7 @@ export interface SkillsCatalogSource {
|
||||
export interface SkillsCatalogItemInstalledBadge {
|
||||
isInstalled: boolean;
|
||||
scope?: 'user' | 'project';
|
||||
source?: 'opencode' | 'agents' | 'claude';
|
||||
}
|
||||
|
||||
export interface ClawdHubSkillMetadata {
|
||||
@@ -1018,6 +1019,7 @@ export interface SkillsInstallRequest {
|
||||
subpath?: string;
|
||||
gitIdentityId?: string;
|
||||
scope: 'user' | 'project';
|
||||
targetSource?: 'opencode' | 'agents';
|
||||
selections: SkillsInstallSelection[];
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
||||
@@ -1026,12 +1028,12 @@ export interface SkillsInstallRequest {
|
||||
export type SkillsInstallError = SkillsRepoScanError | {
|
||||
kind: 'conflicts';
|
||||
message: string;
|
||||
conflicts: Array<{ skillName: string; scope: 'user' | 'project' }>;
|
||||
conflicts: Array<{ skillName: string; scope: 'user' | 'project'; source?: 'opencode' | 'agents' }>;
|
||||
};
|
||||
|
||||
export interface SkillsInstallResponse {
|
||||
ok: boolean;
|
||||
installed?: Array<{ skillName: string; scope: 'user' | 'project' }>;
|
||||
installed?: Array<{ skillName: string; scope: 'user' | 'project'; source?: 'opencode' | 'agents' }>;
|
||||
skipped?: Array<{ skillName: string; reason: string }>;
|
||||
error?: SkillsInstallError;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const getCurrentDirectory = (): string | null => {
|
||||
};
|
||||
|
||||
export type SkillScope = 'user' | 'project';
|
||||
export type SkillSource = 'opencode' | 'claude';
|
||||
export type SkillSource = 'opencode' | 'claude' | 'agents';
|
||||
|
||||
export interface SupportingFile {
|
||||
name: string;
|
||||
@@ -83,6 +83,7 @@ export interface SkillConfig {
|
||||
description: string;
|
||||
instructions?: string;
|
||||
scope?: SkillScope;
|
||||
source?: SkillSource;
|
||||
supportingFiles?: Array<{ path: string; content: string }>;
|
||||
}
|
||||
|
||||
@@ -94,6 +95,7 @@ export interface PendingFile {
|
||||
export interface SkillDraft {
|
||||
name: string;
|
||||
scope: SkillScope;
|
||||
source?: SkillSource;
|
||||
description: string;
|
||||
instructions?: string;
|
||||
pendingFiles?: PendingFile[];
|
||||
@@ -217,6 +219,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
|
||||
if (config.instructions) skillConfig.instructions = config.instructions;
|
||||
if (config.scope) skillConfig.scope = config.scope;
|
||||
if (config.source) skillConfig.source = config.source;
|
||||
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
|
||||
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
|
||||
Reference in New Issue
Block a user