From e3b0416d0a85e5e593beac135cb14ae13ff2b229 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:23:02 +0300 Subject: [PATCH] fix(skills): preserve uploaded skill folders Folder drops discarded supporting scripts and assets. Keep relative paths and upload every file from the selected skill folder. Use the selected folder name for installation and cover it in provider tests. --- server/modules/providers/tests/skills.test.ts | 3 +- src/components/skills/view/ProviderSkills.tsx | 60 +++++++++++++------ 2 files changed, 45 insertions(+), 18 deletions(-) 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`.