diff --git a/web/src/components/config/ConfigRawEditor.tsx b/web/src/components/config/ConfigRawEditor.tsx
index cb550dad7..8eb8dc133 100644
--- a/web/src/components/config/ConfigRawEditor.tsx
+++ b/web/src/components/config/ConfigRawEditor.tsx
@@ -20,6 +20,7 @@ export default function ConfigRawEditor({ rawToml, onChange, disabled }: Props)
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
spellCheck={false}
+ aria-label="Raw TOML configuration editor"
className="w-full min-h-[500px] bg-gray-950 text-gray-200 font-mono text-sm p-4 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset disabled:opacity-50"
style={{ tabSize: 4 }}
/>
diff --git a/web/src/components/config/ConfigSection.tsx b/web/src/components/config/ConfigSection.tsx
index b85dd53d0..ad06da01f 100644
--- a/web/src/components/config/ConfigSection.tsx
+++ b/web/src/components/config/ConfigSection.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, ChevronDown } from 'lucide-react';
import type { SectionDef, FieldDef } from './types';
import TextField from './fields/TextField';
@@ -47,14 +47,25 @@ export default function ConfigSection({
visibleFields,
}: Props) {
const [collapsed, setCollapsed] = useState(section.defaultCollapsed ?? false);
+ const sectionPanelId = useMemo(
+ () =>
+ `config-section-${(section.path || 'root').replace(/[^a-zA-Z0-9_-]/g, '-')}`,
+ [section.path],
+ );
const Icon = section.icon;
const fields = visibleFields ?? section.fields;
+ useEffect(() => {
+ setCollapsed(section.defaultCollapsed ?? false);
+ }, [section.path, section.defaultCollapsed]);
+
return (
{!collapsed && (
-
+
{fields.map((field) => {
const value = getFieldValue(section.path, field.key);
const masked = isFieldMasked(section.path, field.key);
diff --git a/web/src/components/config/fields/NumberField.tsx b/web/src/components/config/fields/NumberField.tsx
index 92106fc54..bf5e0eec1 100644
--- a/web/src/components/config/fields/NumberField.tsx
+++ b/web/src/components/config/fields/NumberField.tsx
@@ -15,7 +15,20 @@ export default function NumberField({ field, value, onChange }: FieldProps) {
}
const n = Number(raw);
if (!isNaN(n)) {
- onChange(field.step && field.step < 1 ? n : Math.floor(n));
+ onChange(n);
+ }
+ }}
+ onBlur={(e) => {
+ if (field.step !== undefined && field.step < 1) {
+ return;
+ }
+ const raw = e.target.value;
+ if (raw === '') {
+ return;
+ }
+ const n = Number(raw);
+ if (!isNaN(n)) {
+ onChange(Math.floor(n));
}
}}
min={field.min}
diff --git a/web/src/components/config/fields/TagListField.tsx b/web/src/components/config/fields/TagListField.tsx
index 990168f25..63b965772 100644
--- a/web/src/components/config/fields/TagListField.tsx
+++ b/web/src/components/config/fields/TagListField.tsx
@@ -32,7 +32,7 @@ export default function TagListField({ field, value, onChange }: FieldProps) {
{tags.map((tag, i) => (
{tag}
diff --git a/web/src/components/config/useConfigForm.ts b/web/src/components/config/useConfigForm.ts
index bd362030f..9b0b9d00e 100644
--- a/web/src/components/config/useConfigForm.ts
+++ b/web/src/components/config/useConfigForm.ts
@@ -1,4 +1,4 @@
-import { useState, useCallback, useRef } from 'react';
+import { useState, useCallback, useRef, useEffect } from 'react';
import { parse, stringify } from 'smol-toml';
import { getConfig, putConfig } from '@/lib/api';
@@ -18,7 +18,9 @@ function scanMasked(obj: unknown, prefix: string, out: Set) {
return;
}
if (Array.isArray(obj)) {
- obj.forEach((item, i) => scanMasked(item, `${prefix}.${i}`, out));
+ obj.forEach((item, i) => {
+ scanMasked(item, `${prefix}.${i}`, out);
+ });
return;
}
if (typeof obj === 'object') {
@@ -90,6 +92,7 @@ export function useConfigForm(): ConfigFormState {
const [parsed, setParsed] = useState({});
const maskedPathsRef = useRef>(new Set());
const dirtyPathsRef = useRef>(new Set());
+ const successTimeoutRef = useRef | null>(null);
const [, forceRender] = useState(0);
const loadConfig = useCallback(async () => {
@@ -121,12 +124,22 @@ export function useConfigForm(): ConfigFormState {
}
}, []);
- // Load on first render
+ // Load once on mount.
const hasLoaded = useRef(false);
- if (!hasLoaded.current) {
- hasLoaded.current = true;
- loadConfig();
- }
+ useEffect(() => {
+ if (!hasLoaded.current) {
+ hasLoaded.current = true;
+ void loadConfig();
+ }
+ }, [loadConfig]);
+
+ useEffect(() => {
+ return () => {
+ if (successTimeoutRef.current) {
+ clearTimeout(successTimeoutRef.current);
+ }
+ };
+ }, []);
const fieldPath = (sectionPath: string, fieldKey: string) =>
sectionPath ? `${sectionPath}.${fieldKey}` : fieldKey;
@@ -235,6 +248,9 @@ export function useConfigForm(): ConfigFormState {
setSaving(true);
setError(null);
setSuccess(null);
+ if (successTimeoutRef.current) {
+ clearTimeout(successTimeoutRef.current);
+ }
try {
let toml: string;
@@ -247,7 +263,7 @@ export function useConfigForm(): ConfigFormState {
setSuccess('Configuration saved successfully.');
// Auto-dismiss success after 4 seconds
- setTimeout(() => setSuccess(null), 4000);
+ successTimeoutRef.current = setTimeout(() => setSuccess(null), 4000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
} finally {
@@ -262,6 +278,10 @@ export function useConfigForm(): ConfigFormState {
const clearMessages = useCallback(() => {
setError(null);
setSuccess(null);
+ if (successTimeoutRef.current) {
+ clearTimeout(successTimeoutRef.current);
+ successTimeoutRef.current = null;
+ }
}, []);
return {