From 846d67796c192c87b4fbcd7b29a393b587a23bb8 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 8 May 2026 19:54:16 -0700 Subject: [PATCH] fix: Add section-level reset to escape stuck partial overrides Some sections, like modelSpecs, have cross-field invariants enforced by zod refinements (e.g. enforce: true requires list.length > 0). When a user toggles enforce on, then later deletes the last list entry, the admin panel cannot save: the path-granular override stream lacks an atomic operation to drop both the toggle and the (now-empty) list at once, so the partial state is rejected by the schema and the section gets stuck. Adds a "Reset section" affordance to every ConfigSection header that clears all overrides under the section path in a single save. On dispatch the handler drops any pending sub-edits under the prefix and writes a single undefined at the section path; on save this routes through resetBaseConfigFieldFn to a $unset overrides. on the LibreChat side, leaving the section back at its librechat.yaml defaults. The button only appears when the user has edit permission and the section has at least one persisted DB override or pending edit, so it stays out of the way during normal editing. Fixes AI-896. --- src/components/configuration/ConfigPage.tsx | 25 +++++ .../configuration/ConfigSection.tsx | 56 +++++++++- .../configuration/ConfigTabContent.tsx | 104 ++++++++++++++++-- src/locales/en/translation.json | 3 + src/types/config-ui.ts | 10 ++ 5 files changed, 184 insertions(+), 14 deletions(-) diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 085b4a0..1805e8f 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -448,6 +448,30 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi }); }, []); + /** Drops pending sub-edits (so the diff dialog doesn't show a partial state) + * and writes a single `undefined` at the section path; on save this routes + * through `resetBaseConfigFieldFn` → `$unset overrides.`. */ + const handleResetSection = useCallback((sectionPath: string) => { + if (!sectionPath) return; + startTransition(() => { + setTouchedPaths((prev) => { + const next = new Set(prev); + next.add(sectionPath); + return next; + }); + setEditedValues((prev) => { + const next: t.FlatConfigMap = {}; + const prefix = `${sectionPath}.`; + for (const [k, v] of Object.entries(prev)) { + if (k === sectionPath || k.startsWith(prefix)) continue; + next[k] = v; + } + next[sectionPath] = undefined; + return next; + }); + }); + }, []); + const handleConfirmSave = useCallback(async () => { if (saving) return; const touched = [...touchedPaths].filter((p) => p in editedValues); @@ -811,6 +835,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi editedValues={editedValues} onFieldChange={handleFieldChange} onResetField={handleResetField} + onResetSection={handleResetSection} profileMap={profileMap} previewMode={false} previewScope={editingScope} diff --git a/src/components/configuration/ConfigSection.tsx b/src/components/configuration/ConfigSection.tsx index 605107f..3bacd50 100644 --- a/src/components/configuration/ConfigSection.tsx +++ b/src/components/configuration/ConfigSection.tsx @@ -1,6 +1,6 @@ import { Icon } from '@clickhouse/click-ui'; import { useState, useCallback, useEffect, useRef } from 'react'; -import type { ReactNode } from 'react'; +import type { ReactNode, MouseEvent, KeyboardEvent } from 'react'; import type * as t from '@/types'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; @@ -34,6 +34,9 @@ export function ConfigSection({ defaultExpanded, inline, showConfiguredOnly, + sectionPath, + onResetSection, + hasOverrides, }: t.ConfigSectionProps) { const localize = useLocalize(); const [isExpanded, setIsExpanded] = useState(() => defaultExpanded ?? configuredCount > 0); @@ -63,9 +66,21 @@ export function ConfigSection({ }; }, []); + const handleResetClick = useCallback( + (e: MouseEvent | KeyboardEvent) => { + e.stopPropagation(); + if (!sectionPath || !onResetSection) return; + const message = localize('com_config_section_reset_confirm', { section: title }); + if (typeof window !== 'undefined' && !window.confirm(message)) return; + onResetSection(sectionPath); + }, + [sectionPath, onResetSection, localize, title], + ); + if (hidden) return null; const hasConfigured = configuredCount > 0; + const showResetSection = !!(sectionPath && onResetSection && hasOverrides); if (inline) { return ( @@ -76,7 +91,14 @@ export function ConfigSection({ >
- {title} + + + {title} + + {showResetSection && ( + + )} + {description && ( {description} )} @@ -123,6 +145,9 @@ export function ConfigSection({ {configuredCount}/{totalCount} )} + {showResetSection && ( + + )} {description && ( @@ -150,3 +175,30 @@ export function ConfigSection({ ); } + +function ResetSectionButton({ + onClick, + title, +}: { + onClick: (e: MouseEvent | KeyboardEvent) => void; + title: string; +}) { + const localize = useLocalize(); + return ( + + ); +} diff --git a/src/components/configuration/ConfigTabContent.tsx b/src/components/configuration/ConfigTabContent.tsx index 7ce6520..54210f9 100644 --- a/src/components/configuration/ConfigTabContent.tsx +++ b/src/components/configuration/ConfigTabContent.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { MultiAccordion } from '@clickhouse/click-ui'; +import { Icon, MultiAccordion } from '@clickhouse/click-ui'; import type * as t from '@/types'; import { SECTION_RENDERERS, SELF_CONTAINED_SECTION_RENDERERS } from './sections'; import { FieldRenderer, SingleFieldRenderer } from './FieldRenderer'; @@ -38,6 +38,7 @@ export function ConfigTabContent({ editedValues, onFieldChange, onResetField, + onResetSection, profileMap, previewMode, previewScope, @@ -83,6 +84,21 @@ export function ConfigTabContent({ return result; }, [filtering, previewChangedPaths, sections]); + const sectionHasOverrides = useMemo(() => { + const result: Record = {}; + const editedPaths = Object.keys(editedValues); + for (const section of sections) { + const sectionPath = section.schemaKey ?? section.id; + const prefix = `${sectionPath}.`; + const overridden = + (dbOverridePaths && + [...dbOverridePaths].some((p) => p === sectionPath || p.startsWith(prefix))) || + editedPaths.some((p) => p === sectionPath || p.startsWith(prefix)); + result[section.id] = !!overridden; + } + return result; + }, [sections, dbOverridePaths, editedValues]); + const sectionCounts = useMemo(() => { return sections.map((s) => { const key = s.schemaKey ?? s.id; @@ -275,6 +291,7 @@ export function ConfigTabContent({ if (group.kind === 'inline') { const { section } = group; const counts = countsById[section.id]; + const sectionPath = section.schemaKey ?? section.id; return ( {renderSectionContent(section)} @@ -301,17 +323,34 @@ export function ConfigTabContent({ defaultValue={group.sections.map((s) => s.id)} data-top-level-accordion > - {group.sections.map((section) => ( - - {renderSectionContent(section)} - - ))} + {group.sections.map((section) => { + const sectionPath = section.schemaKey ?? section.id; + const canResetSection = + !!onResetSection && + !!permissions?.canEdit && + !fieldsDisabled && + !!sectionHasOverrides[section.id]; + const titleNode = canResetSection ? ( + + ) : ( + localize(section.titleKey) + ); + return ( + + {renderSectionContent(section)} + + ); + })} ); })} @@ -333,3 +372,44 @@ function hasChangedDescendant( } return false; } + +function AccordionSectionHeader({ + title, + sectionPath, + onResetSection, +}: { + title: string; + sectionPath: string; + onResetSection: (path: string) => void; +}) { + const localize = useLocalize(); + const triggerReset = () => { + const message = localize('com_config_section_reset_confirm', { section: title }); + if (typeof window !== 'undefined' && !window.confirm(message)) return; + onResetSection(sectionPath); + }; + return ( + + {title} + + + ); +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9719e25..db20bfe 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -93,6 +93,8 @@ "com_config_default": "Default", "com_config_reset_default": "Reset to default", "com_config_reset_confirm": "Reset \"{{field}}\" to its default value? This removes the DB override.", + "com_config_reset_section": "Reset section", + "com_config_section_reset_confirm": "Reset all overrides under \"{{section}}\"? Saving will remove every configured value in this section and revert to defaults from librechat.yaml.", "com_config_section_configured_count": "{{count}} of {{total}} configured", "com_config_default_hint": "(default: {{value}})", "com_config_entry_n": "Entry {{n}}", @@ -909,6 +911,7 @@ "com_a11y_rename_entry": "Rename {{name}}", "com_a11y_remove_override": "Remove override for {{name}}", "com_a11y_reset_to_default": "Reset {{name}} to default", + "com_a11y_reset_section": "Reset section {{name}}", "com_a11y_add_item": "Add {{name}}", "com_access_tab_capabilities": "Capabilities", "com_cap_title": "System capabilities", diff --git a/src/types/config-ui.ts b/src/types/config-ui.ts index b13e873..c185df4 100644 --- a/src/types/config-ui.ts +++ b/src/types/config-ui.ts @@ -68,6 +68,7 @@ export interface ConfigTabContentProps { editedValues: FlatConfigMap; onFieldChange: (path: string, value: ConfigValue) => void; onResetField?: (path: string) => void; + onResetSection?: (sectionPath: string) => void; profileMap?: Record; previewMode?: boolean; previewScope?: ConfigScope; @@ -131,6 +132,15 @@ export interface ConfigSectionProps { defaultExpanded?: boolean; inline?: boolean; showConfiguredOnly?: boolean; + /** + * Path of this section within the schema (e.g. `modelSpecs`, + * `endpoints.azureOpenAI`). When provided alongside `onResetSection` and at + * least one configured override under it, the header renders a "Reset + * section" affordance that atomically clears all overrides under the path. + */ + sectionPath?: string; + onResetSection?: (sectionPath: string) => void; + hasOverrides?: boolean; } export interface ConfirmSaveDialogProps {