diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx
index bf617ca04..7e3255c50 100644
--- a/web/src/components/dashboard/config-editor.tsx
+++ b/web/src/components/dashboard/config-editor.tsx
@@ -1,6 +1,6 @@
'use client';
-import { Loader2, Save } from 'lucide-react';
+import { AlertCircle, CheckCircle2, Loader2, RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -136,6 +136,15 @@ export function ConfigEditor() {
const abortRef = useRef(null);
+ /** Auto-save status indicator. */
+ const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
+ /** Debounce timer for auto-save. */
+ const autoSaveTimerRef = useRef | null>(null);
+ /** True while the initial config load is in progress — suppresses auto-save. */
+ const isLoadingConfigRef = useRef(false);
+ /** Ref tracking latest draftConfig for race-condition detection during save. */
+ const draftConfigRef = useRef(null);
+
const updateDraftConfig = useCallback((updater: (prev: GuildConfig) => GuildConfig) => {
setDraftConfig((prev) => updater((prev ?? {}) as GuildConfig));
}, []);
@@ -169,7 +178,7 @@ export function ConfigEditor() {
}, []);
// ── Load config when guild changes ─────────────────────────────
- const fetchConfig = useCallback(async (id: string) => {
+ const fetchConfig = useCallback(async (id: string, { skipSaveStatusReset = false } = {}) => {
if (!id) return;
abortRef.current?.abort();
@@ -178,6 +187,7 @@ export function ConfigEditor() {
setLoading(true);
setError(null);
+ isLoadingConfigRef.current = true;
try {
const res = await fetch(`/api/guilds/${encodeURIComponent(id)}/config`, {
@@ -209,10 +219,19 @@ export function ConfigEditor() {
}
setSavedConfig(data);
setDraftConfig(structuredClone(data));
+ // Reset status after a successful reload; clear any stale error
+ if (!skipSaveStatusReset) {
+ setSaveStatus('idle');
+ }
+ // Use a macrotask so the state setter for draftConfig fires before we clear the flag
+ setTimeout(() => {
+ isLoadingConfigRef.current = false;
+ }, 0);
setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n'));
setProtectRoleIdsRaw((data.moderation?.protectRoles?.roleIds ?? []).join(', '));
} catch (err) {
if ((err as Error).name === 'AbortError') return;
+ isLoadingConfigRef.current = false;
const msg = (err as Error).message || 'Failed to load config';
setError(msg);
toast.error('Failed to load config', { description: msg });
@@ -226,6 +245,11 @@ export function ConfigEditor() {
return () => abortRef.current?.abort();
}, [guildId, fetchConfig]);
+ // Keep draftConfigRef in sync for race-condition detection in saveChanges
+ useEffect(() => {
+ draftConfigRef.current = draftConfig;
+ }, [draftConfig]);
+
// ── Derived state ──────────────────────────────────────────────
const hasChanges = useMemo(() => {
if (!savedConfig || !draftConfig) return false;
@@ -272,10 +296,10 @@ export function ConfigEditor() {
}
const patches = computePatches(savedConfig, draftConfig);
- if (patches.length === 0) {
- toast.info('No changes to save.');
- return;
- }
+ if (patches.length === 0) return;
+
+ // Snapshot draft before saving to detect changes made during in-flight save
+ const draftSnapshot = draftConfig;
// Group patches by top-level section for batched requests
const bySection = new Map>();
@@ -290,6 +314,7 @@ export function ConfigEditor() {
}
setSaving(true);
+ setSaveStatus('saving');
// Shared AbortController for all section saves - aborts all in-flight requests on 401
const saveAbortController = new AbortController();
@@ -354,29 +379,64 @@ export function ConfigEditor() {
return updated;
});
}
+ setSaveStatus('error');
toast.error('Some sections failed to save', {
description: `Failed: ${failedSections.join(', ')}`,
});
} else {
- toast.success('Config saved successfully!');
- // Full success: reload to get the authoritative version from the server
- await fetchConfig(guildId);
+ setSaveStatus('saved');
+ // Full success: check if draft changed during save (user edited while saving)
+ if (deepEqual(draftConfigRef.current, draftSnapshot)) {
+ // No changes during save — safe to reload authoritative version
+ await fetchConfig(guildId, { skipSaveStatusReset: true });
+ } else {
+ // Draft changed during save — don't overwrite user's new changes
+ // Update savedConfig to reflect what was successfully persisted
+ setSavedConfig(structuredClone(draftSnapshot));
+ }
}
} catch (err) {
const msg = (err as Error).message || 'Failed to save config';
+ setSaveStatus('error');
toast.error('Failed to save config', { description: msg });
} finally {
setSaving(false);
}
}, [guildId, savedConfig, draftConfig, hasValidationErrors, fetchConfig]);
+ // ── Auto-save: debounce 500ms after draft changes ──────────────
+ useEffect(() => {
+ // Don't auto-save during initial config load or if no changes
+ if (isLoadingConfigRef.current || !hasChanges || hasValidationErrors || saving) return;
+
+ // Clear any pending timer
+ if (autoSaveTimerRef.current) {
+ clearTimeout(autoSaveTimerRef.current);
+ }
+
+ autoSaveTimerRef.current = setTimeout(() => {
+ void saveChanges();
+ }, 500);
+
+ return () => {
+ if (autoSaveTimerRef.current) {
+ clearTimeout(autoSaveTimerRef.current);
+ }
+ };
+ }, [draftConfig, hasChanges, hasValidationErrors, saving, saveChanges]);
+
// ── Keyboard shortcut: Ctrl/Cmd+S to save ──────────────────────
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (hasChanges && !saving && !hasValidationErrors) {
- saveChanges();
+ // Cancel any pending debounce timer and save immediately
+ if (autoSaveTimerRef.current) {
+ clearTimeout(autoSaveTimerRef.current);
+ autoSaveTimerRef.current = null;
+ }
+ void saveChanges();
}
}
}
@@ -697,38 +757,24 @@ export function ConfigEditor() {
Manage AI, welcome messages, and other settings.
-
+
+ {/* Auto-save status indicator */}
+
-
- {/* Unsaved changes banner */}
- {hasChanges && (
+ {/* Validation error banner */}
+ {hasChanges && hasValidationErrors && (
)}
@@ -2241,6 +2287,58 @@ export function ConfigEditor() {
);
}
+// ── Auto-Save Status Indicator ────────────────────────────────
+
+interface AutoSaveStatusProps {
+ status: 'idle' | 'saving' | 'saved' | 'error';
+ onRetry: () => void;
+}
+
+/**
+ * Displays the current auto-save status with an icon and text.
+ *
+ * Shows nothing when idle, a spinner while saving, a check icon on success,
+ * and an error state with a retry button on failure.
+ */
+function AutoSaveStatus({ status, onRetry }: AutoSaveStatusProps) {
+ if (status === 'idle') return null;
+
+ if (status === 'saving') {
+ return (
+
+ );
+ }
+
+ if (status === 'saved') {
+ return (
+
+ );
+ }
+
+ // error state
+ return (
+
+ );
+}
+
// ── Toggle Switch ───────────────────────────────────────────────
interface ToggleSwitchProps {
diff --git a/web/tests/components/dashboard/config-editor-autosave.test.tsx b/web/tests/components/dashboard/config-editor-autosave.test.tsx
new file mode 100644
index 000000000..92959e08b
--- /dev/null
+++ b/web/tests/components/dashboard/config-editor-autosave.test.tsx
@@ -0,0 +1,317 @@
+/**
+ * Tests for the auto-save feature in ConfigEditor.
+ *
+ * Covers:
+ * - AutoSaveStatus component renders the correct UI for idle, saving, saved, and error states
+ * - ConfigEditor loads config without triggering auto-save (no PATCH on mount)
+ * - Validation error banner appears when system prompt exceeds max length
+ * - Retry button is present in the error state
+ */
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
+
+// ── Mocks ─────────────────────────────────────────────────────────
+
+vi.mock('sonner', () => ({
+ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() },
+ Toaster: () => null,
+}));
+
+vi.mock('@/components/dashboard/reset-defaults-button', () => ({
+ DiscardChangesButton: ({
+ onReset,
+ disabled,
+ }: {
+ onReset: () => void;
+ disabled: boolean;
+ }) => (
+
+ ),
+}));
+
+vi.mock('@/components/dashboard/system-prompt-editor', () => ({
+ SystemPromptEditor: ({
+ value,
+ onChange,
+ }: {
+ value: string;
+ onChange: (v: string) => void;
+ }) => (
+