diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index 9308401908..cd429390c1 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -470,11 +470,52 @@ export class CustomModesManager { public async updateCustomMode(slug: string, config: ModeConfig): Promise { 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}`) } diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 170d03b0e4..7c0c0719b5 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -110,9 +110,14 @@ const ModesView = ({ onDone }: ModesViewProps) => { const [searchValue, setSearchValue] = useState("") const searchInputRef = useRef(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("") + const [localModeDescription, setLocalModeDescription] = useState("") + const [localModeRoleDefinition, setLocalModeRoleDefinition] = useState("") + const [localModeWhenToUse, setLocalModeWhenToUse] = useState("") + const [localModeCustomInstructions, setLocalModeCustomInstructions] = useState("") const [currentEditingModeSlug, setCurrentEditingModeSlug] = useState(null) + const [currentEditingField, setCurrentEditingField] = useState(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"