From 5210f9bd096f01de5310cdcfdd51c6ba8d764871 Mon Sep 17 00:00:00 2001
From: Pip Build
Date: Sun, 1 Mar 2026 23:09:24 -0500
Subject: [PATCH 1/4] feat(dashboard): replace manual save with auto-save
(500ms debounce)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove 'Save Changes' button; saving now fires automatically 500ms
after the last config change (no changes → no network call)
- Add saveStatus state ('idle' | 'saving' | 'saved' | 'error') with
AutoSaveStatus component showing spinner, check, or error+retry
- Add isLoadingConfigRef guard so the initial fetchConfig load never
triggers a spurious PATCH
- Ctrl+S still works: clears debounce timer and saves immediately
- Keep 'beforeunload' warning for validation errors that block save
- Replace yellow unsaved-changes banner with a destructive validation
error banner (only shown when save is actually blocked)
- Error state shows 'Save failed' + 'Retry' button for user recovery
Closes #189
---
.../components/dashboard/config-editor.tsx | 135 ++++++++++++++----
1 file changed, 107 insertions(+), 28 deletions(-)
diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx
index bf617ca0..8e0ced3a 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,13 @@ 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);
+
const updateDraftConfig = useCallback((updater: (prev: GuildConfig) => GuildConfig) => {
setDraftConfig((prev) => updater((prev ?? {}) as GuildConfig));
}, []);
@@ -178,6 +185,7 @@ export function ConfigEditor() {
setLoading(true);
setError(null);
+ isLoadingConfigRef.current = true;
try {
const res = await fetch(`/api/guilds/${encodeURIComponent(id)}/config`, {
@@ -209,6 +217,12 @@ export function ConfigEditor() {
}
setSavedConfig(data);
setDraftConfig(structuredClone(data));
+ // Reset status after a successful reload; clear any stale error
+ setSaveStatus('idle');
+ // Use a microtask 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) {
@@ -272,10 +286,7 @@ export function ConfigEditor() {
}
const patches = computePatches(savedConfig, draftConfig);
- if (patches.length === 0) {
- toast.info('No changes to save.');
- return;
- }
+ if (patches.length === 0) return;
// Group patches by top-level section for batched requests
const bySection = new Map>();
@@ -290,6 +301,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 +366,58 @@ export function ConfigEditor() {
return updated;
});
}
+ setSaveStatus('error');
toast.error('Some sections failed to save', {
description: `Failed: ${failedSections.join(', ')}`,
});
} else {
- toast.success('Config saved successfully!');
+ setSaveStatus('saved');
// Full success: reload to get the authoritative version from the server
await fetchConfig(guildId);
}
} 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 ──────────────
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally omits saveChanges and saving to prevent re-trigger loops
+ 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]);
+
// ── 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 +738,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 +2268,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 {
From eb894ad5aa968f1bdc34c9463f6de105863ce9e5 Mon Sep 17 00:00:00 2001
From: Pip Build
Date: Sun, 1 Mar 2026 23:09:28 -0500
Subject: [PATCH 2/4] test(dashboard): add auto-save tests for ConfigEditor
- No PATCH on initial config load
- Validation error banner suppresses auto-save
- 'Saving...' spinner visible while PATCH in-flight
- 'Save failed' + Retry button on PATCH error
---
.../dashboard/config-editor-autosave.test.tsx | 240 ++++++++++++++++++
1 file changed, 240 insertions(+)
create mode 100644 web/tests/components/dashboard/config-editor-autosave.test.tsx
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 00000000..0ebebb4f
--- /dev/null
+++ b/web/tests/components/dashboard/config-editor-autosave.test.tsx
@@ -0,0 +1,240 @@
+/**
+ * Tests for the auto-save feature in ConfigEditor.
+ *
+ * Covers:
+ * - AutoSaveStatus component renders the correct UI for each state
+ * - 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;
+ }) => (
+