feat: implement comprehensive field validation for custom modes

- Enhanced CustomModesManager.ts with comprehensive validation for all fields
- Added validation for required fields (name, roleDefinition, slug) and optional fields (description, whenToUse, customInstructions)
- Implemented checks to prevent empty strings from being saved (should be undefined instead)
- Extended local state management pattern from PR #5794 to ALL editable fields in ModesView.tsx
- Added local state variables for all fields: localModeName, localModeDescription, localModeRoleDefinition, localModeWhenToUse, localModeCustomInstructions
- Implemented onFocus/onChange/onBlur pattern for all editable fields to prevent empty field saves
- Users can now visually clear fields during editing but empty values are prevented from being saved
- This prevents custom modes from disappearing due to empty field validation issues

Fixes custom modes disappearing and creation issues by extending validation pattern from PR #5794
This commit is contained in:
Roo Code 2025-07-18 13:22:19 +00:00
parent acb360b9de
commit 45df949a17
2 changed files with 188 additions and 22 deletions

View file

@ -470,11 +470,52 @@ export class CustomModesManager {
public async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
try {
// Validate the mode configuration before saving
// Comprehensive validation to prevent empty fields from being saved
const validationErrors: string[] = []
// Validate required fields are not empty
if (!config.name || config.name.trim() === "") {
validationErrors.push("Mode name cannot be empty")
}
if (!config.roleDefinition || config.roleDefinition.trim() === "") {
validationErrors.push("Role definition cannot be empty")
}
if (!config.slug || config.slug.trim() === "") {
validationErrors.push("Mode slug cannot be empty")
}
// Validate optional fields are not empty strings (they should be undefined if not provided)
if (config.description !== undefined && config.description.trim() === "") {
validationErrors.push("Description cannot be empty (use undefined instead)")
}
if (config.whenToUse !== undefined && config.whenToUse.trim() === "") {
validationErrors.push("When to use cannot be empty (use undefined instead)")
}
if (config.customInstructions !== undefined && config.customInstructions.trim() === "") {
validationErrors.push("Custom instructions cannot be empty (use undefined instead)")
}
// Validate groups array is not empty
if (!config.groups || !Array.isArray(config.groups) || config.groups.length === 0) {
validationErrors.push("At least one tool group must be selected")
}
// If we have validation errors, throw them
if (validationErrors.length > 0) {
const errorMessage = `Invalid mode configuration: ${validationErrors.join(", ")}`
logger.error(`Validation failed for mode ${slug}`, { errors: validationErrors })
throw new Error(errorMessage)
}
// Use schema validation as a secondary check
const validationResult = modeConfigSchema.safeParse(config)
if (!validationResult.success) {
const errors = validationResult.error.errors.map((e: any) => e.message).join(", ")
logger.error(`Invalid mode configuration for ${slug}`, { errors: validationResult.error.errors })
logger.error(`Schema validation failed for mode ${slug}`, { errors: validationResult.error.errors })
throw new Error(`Invalid mode configuration: ${errors}`)
}

View file

@ -110,9 +110,14 @@ const ModesView = ({ onDone }: ModesViewProps) => {
const [searchValue, setSearchValue] = useState("")
const searchInputRef = useRef<HTMLInputElement>(null)
// Local state for mode name input to allow visual emptying
// Local state for all editable fields to allow visual emptying but prevent saving empty values
const [localModeName, setLocalModeName] = useState<string>("")
const [localModeDescription, setLocalModeDescription] = useState<string>("")
const [localModeRoleDefinition, setLocalModeRoleDefinition] = useState<string>("")
const [localModeWhenToUse, setLocalModeWhenToUse] = useState<string>("")
const [localModeCustomInstructions, setLocalModeCustomInstructions] = useState<string>("")
const [currentEditingModeSlug, setCurrentEditingModeSlug] = useState<string | null>(null)
const [currentEditingField, setCurrentEditingField] = useState<string | null>(null)
// Direct update functions
const updateAgentPrompt = useCallback(
@ -222,11 +227,16 @@ const ModesView = ({ onDone }: ModesViewProps) => {
}
}, [getCurrentMode, checkRulesDirectory, hasRulesToExport])
// Reset local name state when mode changes
// Reset all local state when mode changes
useEffect(() => {
if (currentEditingModeSlug && currentEditingModeSlug !== visualMode) {
setCurrentEditingModeSlug(null)
setCurrentEditingField(null)
setLocalModeName("")
setLocalModeDescription("")
setLocalModeRoleDefinition("")
setLocalModeWhenToUse("")
setLocalModeCustomInstructions("")
}
}, [visualMode, currentEditingModeSlug])
@ -825,30 +835,58 @@ const ModesView = ({ onDone }: ModesViewProps) => {
value={(() => {
const customMode = findModeBySlug(visualMode, customModes)
const prompt = customModePrompts?.[visualMode] as PromptComponent
// Use local state if currently editing this field
if (currentEditingField === "roleDefinition" && currentEditingModeSlug === visualMode) {
return localModeRoleDefinition
}
return (
customMode?.roleDefinition ??
prompt?.roleDefinition ??
getRoleDefinition(visualMode)
)
})()}
onFocus={() => {
const customMode = findModeBySlug(visualMode, customModes)
const prompt = customModePrompts?.[visualMode] as PromptComponent
const currentValue = customMode?.roleDefinition ??
prompt?.roleDefinition ??
getRoleDefinition(visualMode)
setCurrentEditingModeSlug(visualMode)
setCurrentEditingField("roleDefinition")
setLocalModeRoleDefinition(currentValue)
}}
onChange={(e) => {
const value =
(e as unknown as CustomEvent)?.detail?.target?.value ||
((e as any).target as HTMLTextAreaElement).value
setLocalModeRoleDefinition(value)
}}
onBlur={() => {
const customMode = findModeBySlug(visualMode, customModes)
if (customMode) {
// For custom modes, update the JSON file
updateCustomMode(visualMode, {
...customMode,
roleDefinition: value.trim() || "",
source: customMode.source || "global",
})
} else {
// For built-in modes, update the prompts
updateAgentPrompt(visualMode, {
roleDefinition: value.trim() || undefined,
})
// Only save if the value is not empty
if (localModeRoleDefinition.trim()) {
if (customMode) {
// For custom modes, update the JSON file
updateCustomMode(visualMode, {
...customMode,
roleDefinition: localModeRoleDefinition.trim(),
source: customMode.source || "global",
})
} else {
// For built-in modes, update the prompts
updateAgentPrompt(visualMode, {
roleDefinition: localModeRoleDefinition.trim(),
})
}
}
// Clear the editing state
setCurrentEditingField(null)
setCurrentEditingModeSlug(null)
}}
className="w-full"
rows={5}
@ -884,26 +922,55 @@ const ModesView = ({ onDone }: ModesViewProps) => {
value={(() => {
const customMode = findModeBySlug(visualMode, customModes)
const prompt = customModePrompts?.[visualMode] as PromptComponent
// Use local state if currently editing this field
if (currentEditingField === "description" && currentEditingModeSlug === visualMode) {
return localModeDescription
}
return customMode?.description ?? prompt?.description ?? getDescription(visualMode)
})()}
onFocus={() => {
const customMode = findModeBySlug(visualMode, customModes)
const prompt = customModePrompts?.[visualMode] as PromptComponent
const currentValue = customMode?.description ??
prompt?.description ??
getDescription(visualMode)
setCurrentEditingModeSlug(visualMode)
setCurrentEditingField("description")
setLocalModeDescription(currentValue || "")
}}
onChange={(e) => {
const value =
(e as unknown as CustomEvent)?.detail?.target?.value ||
((e as any).target as HTMLTextAreaElement).value
setLocalModeDescription(value)
}}
onBlur={() => {
const customMode = findModeBySlug(visualMode, customModes)
// For description, allow empty values (they become undefined)
const trimmedValue = localModeDescription.trim()
const finalValue = trimmedValue || undefined
if (customMode) {
// For custom modes, update the JSON file
updateCustomMode(visualMode, {
...customMode,
description: value.trim() || undefined,
description: finalValue,
source: customMode.source || "global",
})
} else {
// For built-in modes, update the prompts
updateAgentPrompt(visualMode, {
description: value.trim() || undefined,
description: finalValue,
})
}
// Clear the editing state
setCurrentEditingField(null)
setCurrentEditingModeSlug(null)
}}
className="w-full"
data-testid={`${getCurrentMode()?.slug || "code"}-description-textfield`}
@ -939,26 +1006,55 @@ const ModesView = ({ onDone }: ModesViewProps) => {
value={(() => {
const customMode = findModeBySlug(visualMode, customModes)
const prompt = customModePrompts?.[visualMode] as PromptComponent
// Use local state if currently editing this field
if (currentEditingField === "whenToUse" && currentEditingModeSlug === visualMode) {
return localModeWhenToUse
}
return customMode?.whenToUse ?? prompt?.whenToUse ?? getWhenToUse(visualMode)
})()}
onFocus={() => {
const customMode = findModeBySlug(visualMode, customModes)
const prompt = customModePrompts?.[visualMode] as PromptComponent
const currentValue = customMode?.whenToUse ??
prompt?.whenToUse ??
getWhenToUse(visualMode)
setCurrentEditingModeSlug(visualMode)
setCurrentEditingField("whenToUse")
setLocalModeWhenToUse(currentValue || "")
}}
onChange={(e) => {
const value =
(e as unknown as CustomEvent)?.detail?.target?.value ||
((e as any).target as HTMLTextAreaElement).value
setLocalModeWhenToUse(value)
}}
onBlur={() => {
const customMode = findModeBySlug(visualMode, customModes)
// For whenToUse, allow empty values (they become undefined)
const trimmedValue = localModeWhenToUse.trim()
const finalValue = trimmedValue || undefined
if (customMode) {
// For custom modes, update the JSON file
updateCustomMode(visualMode, {
...customMode,
whenToUse: value.trim() || undefined,
whenToUse: finalValue,
source: customMode.source || "global",
})
} else {
// For built-in modes, update the prompts
updateAgentPrompt(visualMode, {
whenToUse: value.trim() || undefined,
whenToUse: finalValue,
})
}
// Clear the editing state
setCurrentEditingField(null)
setCurrentEditingModeSlug(null)
}}
className="w-full"
rows={4}
@ -1094,22 +1190,47 @@ const ModesView = ({ onDone }: ModesViewProps) => {
value={(() => {
const customMode = findModeBySlug(visualMode, customModes)
const prompt = customModePrompts?.[visualMode] as PromptComponent
// Use local state if currently editing this field
if (currentEditingField === "customInstructions" && currentEditingModeSlug === visualMode) {
return localModeCustomInstructions
}
return (
customMode?.customInstructions ??
prompt?.customInstructions ??
getCustomInstructions(mode, customModes)
)
})()}
onFocus={() => {
const customMode = findModeBySlug(visualMode, customModes)
const prompt = customModePrompts?.[visualMode] as PromptComponent
const currentValue = customMode?.customInstructions ??
prompt?.customInstructions ??
getCustomInstructions(mode, customModes)
setCurrentEditingModeSlug(visualMode)
setCurrentEditingField("customInstructions")
setLocalModeCustomInstructions(currentValue || "")
}}
onChange={(e) => {
const value =
(e as unknown as CustomEvent)?.detail?.target?.value ||
((e as any).target as HTMLTextAreaElement).value
setLocalModeCustomInstructions(value)
}}
onBlur={() => {
const customMode = findModeBySlug(visualMode, customModes)
// For customInstructions, allow empty values (they become undefined)
const trimmedValue = localModeCustomInstructions.trim()
const finalValue = trimmedValue || undefined
if (customMode) {
// For custom modes, update the JSON file
updateCustomMode(visualMode, {
...customMode,
customInstructions: value.trim() || undefined,
customInstructions: finalValue,
source: customMode.source || "global",
})
} else {
@ -1117,9 +1238,13 @@ const ModesView = ({ onDone }: ModesViewProps) => {
const existingPrompt = customModePrompts?.[visualMode] as PromptComponent
updateAgentPrompt(visualMode, {
...existingPrompt,
customInstructions: value.trim(),
customInstructions: finalValue,
})
}
// Clear the editing state
setCurrentEditingField(null)
setCurrentEditingModeSlug(null)
}}
rows={10}
className="w-full"