Skip to content

Commit abdb8f3

Browse files
📣 fix: Surface Skill Creation Feedback (#15649)
* fix: show inline skill creation errors and progress * fix: address skill creation review findings * style: flatten skill error selection --------- Co-authored-by: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com> Co-authored-by: Danny Avila <danny@librechat.ai>
1 parent 3f92853 commit abdb8f3

3 files changed

Lines changed: 116 additions & 13 deletions

File tree

client/src/components/Skills/dialogs/CreateSkillDialog.tsx

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import { useForm } from 'react-hook-form';
22
import { useNavigate } from 'react-router-dom';
3-
import {
4-
SKILL_NAME_PATTERN,
5-
SKILL_NAME_MAX_LENGTH,
6-
SKILL_DESCRIPTION_MAX_LENGTH,
7-
} from 'librechat-data-provider';
83
import {
94
Input,
105
Label,
@@ -14,6 +9,12 @@ import {
149
TextareaAutosize,
1510
useToastContext,
1611
} from '@librechat/client';
12+
import {
13+
SKILL_NAME_PATTERN,
14+
SKILL_NAME_MAX_LENGTH,
15+
SKILL_BODY_MAX_LENGTH,
16+
SKILL_DESCRIPTION_MAX_LENGTH,
17+
} from 'librechat-data-provider';
1718
import type { TSkill } from 'librechat-data-provider';
1819
import type { FormEvent } from 'react';
1920
import { useCreateSkillMutation } from '~/data-provider';
@@ -40,6 +41,11 @@ interface FormValues {
4041
body: string;
4142
}
4243

44+
interface SkillValidationIssue {
45+
field: string;
46+
code: string;
47+
}
48+
4349
/**
4450
* Minimal create-skill dialog matching Claude.ai's "Write skill instructions"
4551
* modal: name, description, instructions. No category, no invocation mode.
@@ -78,9 +84,48 @@ export default function CreateSkillDialog({
7884
navigate(`/skills/${skill._id}`);
7985
},
8086
onError: (error: unknown) => {
81-
const message =
82-
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ??
83-
localize('com_ui_skill_create_error');
87+
const response = (
88+
error as {
89+
response?: {
90+
status?: number;
91+
data?: { error?: string; message?: string; issues?: SkillValidationIssue[] };
92+
};
93+
}
94+
)?.response;
95+
const getIssueMessage = ({ field, code }: SkillValidationIssue) => {
96+
if (field === 'name' && code === 'REQUIRED') {
97+
return localize('com_ui_skill_name_required');
98+
}
99+
if (field === 'name' && code === 'TOO_LONG') {
100+
return localize('com_ui_skill_name_too_long', { 0: SKILL_NAME_MAX_LENGTH });
101+
}
102+
if (field === 'name' && code === 'INVALID_FORMAT') {
103+
return localize('com_ui_skill_name_invalid');
104+
}
105+
if (field === 'name' && (code === 'RESERVED_PREFIX' || code === 'RESERVED_WORD')) {
106+
return localize('com_ui_skill_name_reserved');
107+
}
108+
if (field === 'description' && code === 'REQUIRED') {
109+
return localize('com_ui_skill_description_required');
110+
}
111+
if (field === 'description' && code === 'TOO_LONG') {
112+
return localize('com_ui_skill_description_too_long', {
113+
0: SKILL_DESCRIPTION_MAX_LENGTH,
114+
});
115+
}
116+
if (field === 'body' && code === 'TOO_LONG') {
117+
return localize('com_ui_skill_instructions_too_long', { 0: SKILL_BODY_MAX_LENGTH });
118+
}
119+
return localize('com_ui_skill_validation_error');
120+
};
121+
const data = response?.data;
122+
let message = data?.message || localize('com_ui_skill_create_error');
123+
if (response?.status === 409) {
124+
message = localize('com_ui_skill_name_exists');
125+
}
126+
if (data?.issues?.length) {
127+
message = data.issues.map(getIssueMessage).join('; ');
128+
}
84129
showToast({ status: 'error', message });
85130
},
86131
});
@@ -166,6 +211,8 @@ export default function CreateSkillDialog({
166211
maxRows={4}
167212
placeholder={localize('com_ui_skill_description_placeholder')}
168213
aria-label={localize('com_ui_description')}
214+
aria-invalid={errors.description ? 'true' : 'false'}
215+
aria-describedby={errors.description ? 'create-skill-description-error' : undefined}
169216
className="w-full resize-none rounded-xl border border-border-medium bg-transparent px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
170217
{...register('description', {
171218
required: localize('com_ui_skill_description_required'),
@@ -177,6 +224,15 @@ export default function CreateSkillDialog({
177224
},
178225
})}
179226
/>
227+
{errors.description && (
228+
<p
229+
id="create-skill-description-error"
230+
className="mt-1 text-sm text-text-destructive"
231+
role="alert"
232+
>
233+
{errors.description.message}
234+
</p>
235+
)}
180236
</div>
181237

182238
{/* Instructions (body) */}
@@ -204,9 +260,10 @@ export default function CreateSkillDialog({
204260
type="submit"
205261
variant="submit"
206262
disabled={submitDisabled}
263+
aria-busy={createSkill.isLoading}
207264
className={cn(submitDisabled && 'opacity-50')}
208265
>
209-
{localize('com_ui_create')}
266+
{localize(createSkill.isLoading ? 'com_ui_creating' : 'com_ui_create')}
210267
</Button>
211268
</div>
212269
</form>

client/src/components/Skills/dialogs/__tests__/CreateSkillDialog.spec.tsx

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const mockMutate = jest.fn();
66
const mockNavigate = jest.fn();
77
const mockSetIsOpen = jest.fn();
88
const mockShowToast = jest.fn();
9+
let mockOnError: ((error: unknown) => void) | undefined;
910

1011
jest.mock('react-router-dom', () => ({
1112
...jest.requireActual('react-router-dom'),
@@ -47,10 +48,13 @@ jest.mock('@librechat/client', () => {
4748
});
4849

4950
jest.mock('~/data-provider', () => ({
50-
useCreateSkillMutation: () => ({
51-
mutate: mockMutate,
52-
isLoading: false,
53-
}),
51+
useCreateSkillMutation: (options: { onError: (error: unknown) => void }) => {
52+
mockOnError = options.onError;
53+
return {
54+
mutate: mockMutate,
55+
isLoading: false,
56+
};
57+
},
5458
}));
5559

5660
jest.mock('~/hooks', () => ({
@@ -61,6 +65,9 @@ jest.mock('~/hooks', () => ({
6165
com_ui_skill_instructions: 'Instructions',
6266
com_ui_cancel: 'Cancel',
6367
com_ui_create: 'Create',
68+
com_ui_skill_create_error: 'Failed to create skill',
69+
com_ui_skill_name_exists: 'A skill with this name already exists',
70+
com_ui_skill_name_reserved: 'This skill name is reserved',
6471
};
6572
return translations[key] ?? key;
6673
},
@@ -73,6 +80,41 @@ jest.mock('~/utils', () => ({
7380
describe('CreateSkillDialog', () => {
7481
beforeEach(() => {
7582
jest.clearAllMocks();
83+
mockOnError = undefined;
84+
});
85+
86+
it('localizes structured validation errors', () => {
87+
render(<CreateSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
88+
89+
mockOnError?.({
90+
response: {
91+
status: 400,
92+
data: {
93+
issues: [{ field: 'name', code: 'RESERVED_WORD', message: 'settings is reserved' }],
94+
},
95+
},
96+
});
97+
98+
expect(mockShowToast).toHaveBeenCalledWith({
99+
status: 'error',
100+
message: 'This skill name is reserved',
101+
});
102+
});
103+
104+
it('preserves middleware messages and localizes duplicate names', () => {
105+
render(<CreateSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
106+
107+
mockOnError?.({ response: { status: 403, data: { message: 'Permission denied' } } });
108+
expect(mockShowToast).toHaveBeenLastCalledWith({
109+
status: 'error',
110+
message: 'Permission denied',
111+
});
112+
113+
mockOnError?.({ response: { status: 409, data: { error: 'server prose' } } });
114+
expect(mockShowToast).toHaveBeenLastCalledWith({
115+
status: 'error',
116+
message: 'A skill with this name already exists',
117+
});
76118
});
77119

78120
it('does not submit an ancestor form when rendered through a portal', async () => {

client/src/locales/en/translation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2126,15 +2126,18 @@
21262126
"com_ui_skill_file_load_error": "Failed to load file content",
21272127
"com_ui_skill_finished": "Loaded {{0}}",
21282128
"com_ui_skill_instructions": "Instructions",
2129+
"com_ui_skill_instructions_too_long": "Instructions must be {{0}} characters or fewer",
21292130
"com_ui_skill_instructions_placeholder": "Enter your skill instructions in markdown...",
21302131
"com_ui_skill_name_invalid": "Use lowercase letters, digits, and dashes only (kebab-case)",
21312132
"com_ui_agent_git_identity": "Git identity",
21322133
"com_ui_agent_git_identity_both_required": "Enter a valid Git name and email, or leave both blank.",
21332134
"com_ui_agent_git_name": "Commit name",
21342135
"com_ui_agent_git_email": "Commit email",
21352136
"com_nav_info_agent_git_identity": "Applied as the author and committer for Git commands run by this agent. Credentials are configured separately.",
2137+
"com_ui_skill_name_exists": "A skill with this name already exists",
21362138
"com_ui_skill_name_placeholder": "brand-guidelines",
21372139
"com_ui_skill_name_required": "Name is required",
2140+
"com_ui_skill_name_reserved": "This skill name is reserved",
21382141
"com_ui_skill_name_too_long": "Name must be {{0}} characters or fewer",
21392142
"com_ui_skill_new_file": "New File",
21402143
"com_ui_skill_new_folder": "New Folder",
@@ -2152,6 +2155,7 @@
21522155
"com_ui_skill_unavailable": "Unavailable skill",
21532156
"com_ui_skill_update_conflict": "Another edit was saved before yours. Reloading the latest version.",
21542157
"com_ui_skill_update_error": "Failed to save skill",
2158+
"com_ui_skill_validation_error": "Check the skill fields and try again",
21552159
"com_ui_skill_updated": "Skill saved",
21562160
"com_ui_skill_upload": "Upload a skill",
21572161
"com_ui_skill_upload_drag": "Drag and drop or click to upload",

0 commit comments

Comments
 (0)