import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { MarkdownPreviewPane } from '@renderer/components/team/editor/MarkdownPreviewPane'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@renderer/components/ui/select'; import { Textarea } from '@renderer/components/ui/textarea'; import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync'; import { useStore } from '@renderer/store'; import { SKILL_ROOT_DEFINITIONS } from '@shared/utils/skillRoots'; import { FileSearch, RotateCcw, X } from 'lucide-react'; import { SkillCodeEditor } from './SkillCodeEditor'; import { buildSkillDraftFiles, buildSkillTemplate, readSkillTemplateContent, updateSkillTemplateFrontmatter, } from './skillDraftUtils'; import { toSuggestedSkillFolderName } from './skillFolderNameUtils'; import { resolveSkillProjectPath } from './skillProjectUtils'; import { SkillReviewDialog } from './SkillReviewDialog'; import { validateSkillFolderName } from './skillValidationUtils'; import type { SkillDetail, SkillInvocationMode, SkillReviewPreview, SkillRootKind, } from '@shared/types/extensions'; type EditorMode = 'create' | 'edit'; interface SkillEditorDialogProps { open: boolean; mode: EditorMode; projectPath: string | null; projectLabel: string | null; allowCodexRootKind: boolean; detail: SkillDetail | null; onClose: () => void; onSaved: (skillId: string | null) => void; } function parseInitialName(detail: SkillDetail | null): string { return detail?.item.name ?? ''; } function parseInitialDescription(detail: SkillDetail | null): string { return detail?.item.description ?? ''; } export const SkillEditorDialog = ({ open, mode, projectPath, projectLabel, allowCodexRootKind, detail, onClose, onSaved, }: SkillEditorDialogProps): React.JSX.Element => { const containerRef = useRef(null); const editorScrollRef = useRef(null); const rawContentRef = useRef(''); const previewSkillUpsert = useStore((s) => s.previewSkillUpsert); const applySkillUpsert = useStore((s) => s.applySkillUpsert); const [scope, setScope] = useState<'user' | 'project'>('user'); const [rootKind, setRootKind] = useState('claude'); const [folderName, setFolderName] = useState(''); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [license, setLicense] = useState(''); const [compatibility, setCompatibility] = useState(''); const [invocationMode, setInvocationMode] = useState('auto'); const [whenToUse, setWhenToUse] = useState(''); const [steps, setSteps] = useState(''); const [notes, setNotes] = useState(''); const [includeScripts, setIncludeScripts] = useState(false); const [includeReferences, setIncludeReferences] = useState(false); const [includeAssets, setIncludeAssets] = useState(false); const [rawContent, setRawContent] = useState(''); const [folderNameEdited, setFolderNameEdited] = useState(false); const [customMarkdownDetected, setCustomMarkdownDetected] = useState(false); const [manualRawEdit, setManualRawEdit] = useState(false); const [showAdvancedEditor, setShowAdvancedEditor] = useState(false); const [splitRatio, setSplitRatio] = useState(0.52); const [isResizing, setIsResizing] = useState(false); const [reviewPreview, setReviewPreview] = useState(null); const [reviewOpen, setReviewOpen] = useState(false); const [reviewLoading, setReviewLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false); const [mutationError, setMutationError] = useState(null); const scrollSync = useMarkdownScrollSync( showAdvancedEditor, detail?.item.id ?? (mode === 'create' ? 'create-skill' : 'edit-skill'), { editorScrollRef } ); const applyFormToRawContent = useCallback( ( nextValues: Partial<{ name: string; description: string; license: string; compatibility: string; invocationMode: SkillInvocationMode; whenToUse: string; steps: string; notes: string; }> ) => { const merged = { name, description, license, compatibility, invocationMode, whenToUse, steps, notes, ...nextValues, }; const nextRawContent = !manualRawEdit && !customMarkdownDetected ? buildSkillTemplate(merged) : updateSkillTemplateFrontmatter(rawContentRef.current, merged); rawContentRef.current = nextRawContent; setRawContent(nextRawContent); }, [ compatibility, description, invocationMode, license, manualRawEdit, customMarkdownDetected, name, notes, steps, whenToUse, ] ); useEffect(() => { if (!open) return; const item = detail?.item; const nextScope = item?.scope ?? (projectPath ? 'project' : 'user'); const nextRootKind = item?.rootKind ?? 'claude'; const nextFolderName = item?.folderName ?? ''; const nextName = parseInitialName(detail); const nextDescription = parseInitialDescription(detail); const nextLicense = item?.license ?? ''; const nextCompatibility = item?.compatibility ?? ''; const nextInvocationMode = item?.invocationMode ?? 'auto'; const nextWhenToUse = 'Use this skill when the task matches these conditions.'; const nextSteps = '1. Describe the first step.\n2. Describe the second step.'; const nextNotes = '- Add caveats, review rules, or references.'; const nextRawContent = detail?.rawContent ?? buildSkillTemplate({ name: nextName || 'New Skill', description: nextDescription || 'Describe what this skill helps with.', license: nextLicense, compatibility: nextCompatibility, invocationMode: nextInvocationMode, whenToUse: nextWhenToUse, steps: nextSteps, notes: nextNotes, }); const rawInput = readSkillTemplateContent(nextRawContent); const suggestedFolderName = toSuggestedSkillFolderName(nextName || 'New Skill'); const hasCustomMarkdown = mode === 'edit' && rawInput.hasUnstructuredBody; setScope(nextScope); setRootKind(nextRootKind); setFolderName(nextFolderName || suggestedFolderName || nextName || ''); setFolderNameEdited(Boolean(item?.folderName)); setName(rawInput.name || nextName || 'New Skill'); setDescription( rawInput.description || nextDescription || 'Describe what this skill helps with.' ); setLicense(rawInput.license ?? nextLicense); setCompatibility(rawInput.compatibility ?? nextCompatibility); setInvocationMode(rawInput.invocationMode ?? nextInvocationMode); setWhenToUse( hasCustomMarkdown ? (rawInput.bodyMarkdown ?? nextRawContent) : (rawInput.whenToUse ?? nextWhenToUse) ); setSteps(hasCustomMarkdown ? '' : (rawInput.steps ?? nextSteps)); setNotes(hasCustomMarkdown ? '' : (rawInput.notes ?? nextNotes)); setIncludeScripts(item?.flags.hasScripts ?? false); setIncludeReferences(item?.flags.hasReferences ?? false); setIncludeAssets(item?.flags.hasAssets ?? false); setCustomMarkdownDetected(hasCustomMarkdown); rawContentRef.current = nextRawContent; setRawContent(nextRawContent); setManualRawEdit(false); setShowAdvancedEditor(hasCustomMarkdown); setReviewPreview(null); setReviewOpen(false); setReviewLoading(false); setSaveLoading(false); setMutationError(null); }, [allowCodexRootKind, detail, mode, open, projectPath]); useEffect(() => { if (open) { return; } setReviewPreview(null); setReviewOpen(false); setReviewLoading(false); setSaveLoading(false); setMutationError(null); }, [open]); useEffect(() => { if (open && mode === 'create' && scope === 'project' && !projectPath) { setScope('user'); } }, [mode, open, projectPath, scope]); useEffect(() => { if (open && mode === 'create' && rootKind === 'codex' && !allowCodexRootKind) { setRootKind('claude'); } }, [allowCodexRootKind, mode, open, rootKind]); useEffect(() => { rawContentRef.current = rawContent; }, [rawContent]); const effectiveProjectPath = useMemo( () => resolveSkillProjectPath( scope, projectPath, mode === 'edit' ? detail?.item.projectRoot : undefined ), [detail?.item.projectRoot, mode, projectPath, scope] ); const request = useMemo( () => ({ scope, rootKind, projectPath: effectiveProjectPath, folderName, existingSkillId: mode === 'edit' ? detail?.item.id : undefined, files: buildSkillDraftFiles({ rawContent, includeScripts, includeReferences, includeAssets, }), }), [ detail?.item.id, folderName, includeAssets, includeReferences, includeScripts, mode, rawContent, rootKind, scope, effectiveProjectPath, ] ); const draftFilePaths = useMemo( () => request.files.map((file) => file.relativePath), [request.files] ); const auxiliaryDraftFilePaths = useMemo( () => draftFilePaths.filter((filePath) => filePath !== 'SKILL.md'), [draftFilePaths] ); const canUseProjectScope = Boolean(projectPath); const visibleRootDefinitions = useMemo( () => SKILL_ROOT_DEFINITIONS.filter( (definition) => definition.rootKind !== 'codex' || allowCodexRootKind || detail?.item.rootKind === 'codex' ), [allowCodexRootKind, detail?.item.rootKind] ); const instructionsLocked = manualRawEdit || customMarkdownDetected; const title = mode === 'create' ? 'Create skill' : 'Edit skill'; const descriptionText = mode === 'create' ? 'Describe the workflow in plain language, review the files that will be created, then save it.' : 'Update this skill, review the resulting file changes, then save it.'; function validateBeforeReview(): string | null { if (!name.trim()) { return 'Add a skill name so people know what this workflow is for.'; } if (!description.trim()) { return 'Add a short description so it is clear what this skill helps with.'; } if (!folderName.trim()) { return 'Choose a folder name for this skill.'; } const folderNameError = validateSkillFolderName(folderName); if (folderNameError) { return folderNameError; } if (scope === 'project' && !effectiveProjectPath) { return 'Project skills need an active project.'; } return null; } const handleMouseMove = useCallback((event: MouseEvent): void => { const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const ratio = (event.clientX - rect.left) / rect.width; setSplitRatio(Math.min(0.75, Math.max(0.25, ratio))); }, []); const handleMouseUp = useCallback((): void => { setIsResizing(false); }, []); useEffect(() => { if (!isResizing) return; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; }, [handleMouseMove, handleMouseUp, isResizing]); async function handleReview(): Promise { const validationError = validateBeforeReview(); if (validationError) { setMutationError(validationError); return; } setReviewLoading(true); setMutationError(null); try { const preview = await previewSkillUpsert(request); setReviewPreview(preview); setReviewOpen(true); } catch (error) { setMutationError(error instanceof Error ? error.message : 'Failed to review skill changes'); } finally { setReviewLoading(false); } } async function handleConfirmSave(): Promise { setSaveLoading(true); setMutationError(null); try { const saved = await applySkillUpsert({ ...request, reviewPlanId: reviewPreview?.planId, }); setReviewOpen(false); onSaved(saved?.item.id ?? detail?.item.id ?? null); onClose(); } catch (error) { setMutationError(error instanceof Error ? error.message : 'Failed to save skill'); } finally { setSaveLoading(false); } } return ( <> !next && onClose()}>
{title} {descriptionText}

1. Basics

Give this skill a clear name, choose who can use it, and decide where it should live.

{ setFolderNameEdited(true); setFolderName(event.target.value); }} disabled={mode === 'edit'} /> {mode === 'create' && (

We suggest this automatically from the skill name so review works right away.

)}
{ const nextValue = event.target.value; setName(nextValue); if (mode === 'create' && !folderNameEdited) { setFolderName(toSuggestedSkillFolderName(nextValue || 'New Skill')); } applyFormToRawContent({ name: nextValue }); }} placeholder="Write concise skill name" />
{ const nextValue = event.target.value; setLicense(nextValue); applyFormToRawContent({ license: nextValue }); }} placeholder="MIT" />
{ const nextValue = event.target.value; setDescription(nextValue); applyFormToRawContent({ description: nextValue }); }} placeholder="What this skill helps with" />
{ const nextValue = event.target.value; setCompatibility(nextValue); applyFormToRawContent({ compatibility: nextValue }); }} placeholder="claude-code, cursor" />
{!customMarkdownDetected && ( <>

2. Instructions

These sections generate the skill file for you, so you do not need to edit markdown unless you want to.