feat(skills): align discovery with Opencode API and improve skills editor UX (#441)

This commit is contained in:
Bohdan Triapitsyn
2026-02-18 12:26:08 +02:00
committed by GitHub
parent 7eba5141fa
commit 14737b6b28
15 changed files with 1231 additions and 286 deletions
@@ -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}`;
}