From 14737b6b281a3b0cd8659d6e924bfa77b2d2723f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 18 Feb 2026 12:26:08 +0200 Subject: [PATCH] feat(skills): align discovery with Opencode API and improve skills editor UX (#441) --- .../components/sections/skills/SkillsPage.tsx | 211 +++++++++-- .../sections/skills/SkillsSidebar.tsx | 21 +- .../skills/catalog/InstallConflictsDialog.tsx | 5 +- .../skills/catalog/InstallFromRepoDialog.tsx | 61 ++-- .../skills/catalog/InstallSkillDialog.tsx | 66 +++- .../sections/skills/skillLocations.ts | 60 ++++ packages/ui/src/lib/api/types.ts | 6 +- packages/ui/src/stores/useSkillsStore.ts | 5 +- packages/vscode/src/bridge.ts | 147 +++++++- packages/vscode/src/opencodeConfig.ts | 318 ++++++++++++---- packages/vscode/src/skillsCatalog.ts | 68 ++-- packages/web/server/index.js | 162 ++++++++- packages/web/server/lib/opencode-config.js | 338 ++++++++++++++---- .../lib/skills-catalog/clawdhub/install.js | 25 +- .../web/server/lib/skills-catalog/install.js | 24 +- 15 files changed, 1231 insertions(+), 286 deletions(-) create mode 100644 packages/ui/src/components/sections/skills/skillLocations.ts diff --git a/packages/ui/src/components/sections/skills/SkillsPage.tsx b/packages/ui/src/components/sections/skills/SkillsPage.tsx index ad12ead1..64b57fa4 100644 --- a/packages/ui/src/components/sections/skills/SkillsPage.tsx +++ b/packages/ui/src/components/sections/skills/SkillsPage.tsx @@ -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('user'); + const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode'); const [description, setDescription] = React.useState(''); const [instructions, setInstructions] = React.useState(''); const [supportingFiles, setSupportingFiles] = React.useState([]); @@ -93,6 +111,82 @@ export const SkillsPage: React.FC = () => { const [originalFileContent, setOriginalFileContent] = React.useState(''); // Track original for change detection const [deleteFilePath, setDeleteFilePath] = React.useState(null); const [isDeletingFile, setIsDeletingFile] = React.useState(false); + const [instructionsEditorHeight, setInstructionsEditorHeight] = React.useState(320); + const [instructionsLanguage, setInstructionsLanguage] = React.useState(null); + const [supportingFileLanguage, setSupportingFileLanguage] = React.useState(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) => { + 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 = () => { {selectedSkill && (

- {selectedSkill.scope === 'project' ? 'Project' : 'User'} skill + {locationLabel(selectedSkill.scope, selectedSkill.source)} skill {selectedSkill.source === 'claude' && ' (Claude-compatible)'}

)} @@ -405,7 +501,7 @@ export const SkillsPage: React.FC = () => { {isNewSkill && (
{ placeholder="skill-name" className="flex-1 text-foreground placeholder:text-muted-foreground" /> - { + const next = locationPartsFrom(v as SkillLocationValue); + setDraftScope(next.scope); + setDraftSource(next.source === 'agents' ? 'agents' : 'opencode'); + }} + > {draftScope === 'user' ? ( ) : ( )} - {draftScope} + {draftSource === 'agents' ? : null} + {locationLabel(draftScope, draftSource)} - -
-
- - User + {SKILL_LOCATION_OPTIONS.map((option) => ( + +
+
+ {option.scope === 'user' ? : } + {option.source === 'agents' ? : null} + {option.label} +
+ {option.description}
- Available in all projects -
- - -
-
- - Project -
- Only in current project -
-
+ + ))}
@@ -476,13 +574,43 @@ export const SkillsPage: React.FC = () => { Detailed instructions for the agent when this skill is loaded

-