diff --git a/server/modules/providers/tests/skills.test.ts b/server/modules/providers/tests/skills.test.ts index d3db9edf..16c660ae 100644 --- a/server/modules/providers/tests/skills.test.ts +++ b/server/modules/providers/tests/skills.test.ts @@ -543,6 +543,7 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu const createdCodexSkills = await providerSkillsService.addProviderSkills('codex', { entries: [ { + directoryName: 'uploaded-codex-folder', fileName: 'SKILL.md', content: '---\nname: codex-global\ndescription: Codex global skill\n---\n\nCodex body.\n', files: [ @@ -559,7 +560,7 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu assert.ok(createdCodexSkill); assert.equal(createdCodexSkill.command, '$codex-global'); assert.equal( - createdCodexSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'codex-global', 'SKILL.md')), + createdCodexSkill.sourcePath.endsWith(path.join('.agents', 'skills', 'uploaded-codex-folder', 'SKILL.md')), true, ); assert.equal( diff --git a/src/components/skills/view/ProviderSkills.tsx b/src/components/skills/view/ProviderSkills.tsx index 6818cb7e..c83aaab4 100644 --- a/src/components/skills/view/ProviderSkills.tsx +++ b/src/components/skills/view/ProviderSkills.tsx @@ -110,8 +110,17 @@ const formatFileSize = (size: number): string => { }; const getBrowserRelativePath = (file: File): string => { - const fileWithRelativePath = file as File & { webkitRelativePath?: string }; - return (fileWithRelativePath.webkitRelativePath || file.name).replace(/\\/g, '/'); + const fileWithRelativePath = file as File & { + path?: string; + webkitRelativePath?: string; + }; + return ( + fileWithRelativePath.webkitRelativePath + || fileWithRelativePath.path + || file.name + ) + .replace(/\\/g, '/') + .replace(/^\/+/, ''); }; const getParentPath = (filePath: string): string => { @@ -242,11 +251,36 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr const groupedSkills = useMemo(() => groupSkillsByScope(filteredSkills), [filteredSkills]); + const queueSkillFolders = useCallback((selectedFiles: File[]) => { + const queuedFolders = buildQueuedSkillFolders(selectedFiles); + setQueuedFiles((previous) => { + const nextMap = new Map(previous.map((file) => [file.id, file])); + queuedFolders.forEach((folder) => nextMap.set(folder.id, folder)); + return [...nextMap.values()].slice(0, 20); + }); + }, []); + const handleDrop = useCallback((files: File[]) => { + const includesDirectory = files.some((file) => getBrowserRelativePath(file).includes('/')); + if (includesDirectory) { + try { + queueSkillFolders(files); + setSubmitError(null); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : 'Failed to read skill folder'); + } + return; + } + const acceptedFiles = files .filter((file) => file.name.toLowerCase().endsWith('.md')) .slice(0, 20); + if (acceptedFiles.length === 0) { + setSubmitError('Drop one or more markdown files or a folder containing SKILL.md.'); + return; + } + setQueuedFiles((previous) => { const nextMap = new Map(previous.map((file) => [file.id, file])); acceptedFiles.forEach((file) => { @@ -264,28 +298,19 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr return [...nextMap.values()].slice(0, 20); }); setSubmitError(null); - }, []); + }, [queueSkillFolders]); const handleFolderSelection = useCallback((selectedFiles: File[]) => { try { - const queuedFolders = buildQueuedSkillFolders(selectedFiles); - setQueuedFiles((previous) => { - const nextMap = new Map(previous.map((file) => [file.id, file])); - queuedFolders.forEach((folder) => nextMap.set(folder.id, folder)); - return [...nextMap.values()].slice(0, 20); - }); + queueSkillFolders(selectedFiles); setSubmitError(null); } catch (error) { setSubmitError(error instanceof Error ? error.message : 'Failed to read skill folder'); } - }, []); + }, [queueSkillFolders]); const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ - accept: { - 'text/markdown': ['.md'], - 'text/plain': ['.md'], - }, - maxFiles: 20, + maxFiles: MAX_SKILL_FOLDER_FILES, noClick: true, noKeyboard: true, onDrop: handleDrop, @@ -303,6 +328,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr try { const entries = await Promise.all(queuedFiles.map(async (queuedFile) => ({ fileName: queuedFile.kind === 'folder' ? `${queuedFile.name}.md` : queuedFile.name, + directoryName: queuedFile.kind === 'folder' ? queuedFile.name : undefined, content: await queuedFile.skillFile.text(), files: queuedFile.kind === 'folder' ? await Promise.all( @@ -388,7 +414,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
-
Drop `.md` files here
+
Drop `.md` files or skill folders here
Upload standalone definitions or choose a full folder to include its scripts, references, and assets.
@@ -459,7 +485,7 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr Install {queuedFiles.length > 0 ? `${queuedFiles.length} Skill${queuedFiles.length === 1 ? '' : 's'}` : 'Skills'} - The skill folder name is taken from the `name` field in `SKILL.md`. + Folder uploads keep the selected folder name; standalone files use the `name` in `SKILL.md`.