feat(FN-3294): document settings autosave behavior in agents.md
Merged step 5 of FN-3294, adding documentation for the settings autosave behavior to `docs/agents.md`. The change is documentation-only, updating the agents configuration reference with 2 additional lines. Fusion-Task-Id: FN-3294
This commit is contained in:
@@ -916,6 +916,10 @@
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.config-saved-indicator--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.heartbeat-procedure-actions {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ const MEMORY_LAYER_DESCRIPTIONS: Record<MemoryFileInfo["layer"], string> = {
|
||||
};
|
||||
|
||||
const DEFAULT_HEARTBEAT_INTERVAL_LABEL = formatHeartbeatInterval(DEFAULT_HEARTBEAT_INTERVAL_MS);
|
||||
const CONFIG_AUTOSAVE_DEBOUNCE_MS = 700;
|
||||
|
||||
function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string): string {
|
||||
if (files.some((file) => file.path === currentPath)) {
|
||||
@@ -3030,9 +3031,12 @@ function ConfigTab({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||
const isDeletableState = agent.state === "idle" || agent.state === "terminated";
|
||||
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const previousAgentRuntimeSyncRef = useRef<{ id: string; updatedAt: string } | null>(null);
|
||||
const lastSavedSignatureRef = useRef<string | null>(null);
|
||||
const saveRevisionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -3188,11 +3192,9 @@ function ConfigTab({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate advanced settings
|
||||
const validationErrors = validateAdvancedSettings(formValues);
|
||||
const validationErrors = useMemo(() => {
|
||||
const nextErrors = validateAdvancedSettings(formValues);
|
||||
|
||||
// Validate heartbeat settings
|
||||
for (const [key, config] of Object.entries({
|
||||
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1 },
|
||||
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5 },
|
||||
@@ -3202,25 +3204,24 @@ function ConfigTab({
|
||||
if (!raw) continue;
|
||||
const num = Number(raw);
|
||||
if (Number.isNaN(num) || !Number.isFinite(num)) {
|
||||
validationErrors[key] = `"${config.label}" must be a valid number`;
|
||||
nextErrors[key] = `"${config.label}" must be a valid number`;
|
||||
} else if (num < config.min) {
|
||||
validationErrors[key] = `"${config.label}" must be at least ${config.min.toLocaleString()}`;
|
||||
nextErrors[key] = `"${config.label}" must be at least ${config.min.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
const messageResponseModeForValidation = heartbeatValues.messageResponseMode?.trim();
|
||||
if (messageResponseModeForValidation && !["immediate", "on-heartbeat"].includes(messageResponseModeForValidation)) {
|
||||
validationErrors.messageResponseMode = "\"Message Response Mode\" must be either immediate or on-heartbeat";
|
||||
nextErrors.messageResponseMode = "\"Message Response Mode\" must be either immediate or on-heartbeat";
|
||||
}
|
||||
|
||||
// Validate budget settings
|
||||
const tokenBudgetRaw = budgetValues.tokenBudget?.trim();
|
||||
if (tokenBudgetRaw) {
|
||||
const num = Number(tokenBudgetRaw);
|
||||
if (Number.isNaN(num) || !Number.isFinite(num)) {
|
||||
validationErrors.tokenBudget = "\"Token Budget\" must be a valid number";
|
||||
nextErrors.tokenBudget = "\"Token Budget\" must be a valid number";
|
||||
} else if (num <= 0) {
|
||||
validationErrors.tokenBudget = "\"Token Budget\" must be greater than 0";
|
||||
nextErrors.tokenBudget = "\"Token Budget\" must be greater than 0";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3228,15 +3229,15 @@ function ConfigTab({
|
||||
if (usageThresholdRaw) {
|
||||
const num = Number(usageThresholdRaw);
|
||||
if (Number.isNaN(num) || !Number.isFinite(num)) {
|
||||
validationErrors.usageThreshold = "\"Usage Threshold\" must be a valid number";
|
||||
nextErrors.usageThreshold = "\"Usage Threshold\" must be a valid number";
|
||||
} else if (num < 1 || num > 100) {
|
||||
validationErrors.usageThreshold = "\"Usage Threshold\" must be between 1 and 100";
|
||||
nextErrors.usageThreshold = "\"Usage Threshold\" must be between 1 and 100";
|
||||
}
|
||||
}
|
||||
|
||||
const budgetPeriodRaw = budgetValues.budgetPeriod?.trim();
|
||||
if (budgetPeriodRaw && !["daily", "weekly", "monthly", "lifetime"].includes(budgetPeriodRaw)) {
|
||||
validationErrors.budgetPeriod = "\"Budget Period\" must be one of: daily, weekly, monthly, lifetime";
|
||||
nextErrors.budgetPeriod = "\"Budget Period\" must be one of: daily, weekly, monthly, lifetime";
|
||||
}
|
||||
|
||||
const resetDayRaw = budgetValues.resetDay?.trim();
|
||||
@@ -3244,22 +3245,24 @@ function ConfigTab({
|
||||
if (resetDayRaw) {
|
||||
const num = Number(resetDayRaw);
|
||||
if (Number.isNaN(num) || !Number.isFinite(num)) {
|
||||
validationErrors.resetDay = "\"Reset Day\" must be a valid number";
|
||||
nextErrors.resetDay = "\"Reset Day\" must be a valid number";
|
||||
} else if (periodForResetDay === "weekly") {
|
||||
if (num < 0 || num > 6 || !Number.isInteger(num)) {
|
||||
validationErrors.resetDay = "\"Reset Day\" must be between 0 (Sunday) and 6 (Saturday) for weekly period";
|
||||
nextErrors.resetDay = "\"Reset Day\" must be between 0 (Sunday) and 6 (Saturday) for weekly period";
|
||||
}
|
||||
} else if (periodForResetDay === "monthly") {
|
||||
if (num < 1 || num > 31 || !Number.isInteger(num)) {
|
||||
validationErrors.resetDay = "\"Reset Day\" must be between 1 and 31 for monthly period";
|
||||
nextErrors.resetDay = "\"Reset Day\" must be between 1 and 31 for monthly period";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nextErrors;
|
||||
}, [formValues, heartbeatValues, budgetValues]);
|
||||
|
||||
const buildSavePayload = useCallback(() => {
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
addToast("Please fix validation errors before saving", "error");
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build the metadata payload — only include non-empty values
|
||||
@@ -3371,33 +3374,100 @@ function ConfigTab({
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: nameValue.trim() || undefined,
|
||||
role: roleValue,
|
||||
title: titleValue.trim() || undefined,
|
||||
icon: iconValue.trim() || undefined,
|
||||
reportsTo: reportsToValue.trim() || undefined,
|
||||
metadata: newMetadata,
|
||||
runtimeConfig: newRuntimeConfig,
|
||||
bundleConfig: newBundleConfig,
|
||||
};
|
||||
}, [agent.metadata, agent.runtimeConfig, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, formValues, heartbeatEnabled, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runtimeMode, selectedRuntimeId, selectedSkills, titleValue, validationErrors]);
|
||||
|
||||
const persistSettings = useCallback(async (showValidationToast: boolean, source: "auto" | "manual") => {
|
||||
const payload = buildSavePayload();
|
||||
if (!payload) {
|
||||
setErrors(validationErrors);
|
||||
if (showValidationToast) {
|
||||
addToast("Please fix validation errors before saving", "error");
|
||||
}
|
||||
if (source === "auto") {
|
||||
setAutoSaveError("Fix validation errors to save changes");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const signature = JSON.stringify(payload);
|
||||
if (signature === lastSavedSignatureRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const revision = ++saveRevisionRef.current;
|
||||
setErrors({});
|
||||
setAutoSaveError(null);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateAgent(agent.id, {
|
||||
name: nameValue.trim() || undefined,
|
||||
role: roleValue,
|
||||
title: titleValue.trim() || undefined,
|
||||
icon: iconValue.trim() || undefined,
|
||||
reportsTo: reportsToValue.trim() || undefined,
|
||||
metadata: newMetadata,
|
||||
runtimeConfig: newRuntimeConfig,
|
||||
bundleConfig: newBundleConfig,
|
||||
}, projectId);
|
||||
addToast("Settings saved", "success");
|
||||
await updateAgent(agent.id, payload, projectId);
|
||||
if (revision !== saveRevisionRef.current) {
|
||||
return false;
|
||||
}
|
||||
lastSavedSignatureRef.current = signature;
|
||||
if (source === "manual") {
|
||||
addToast("Settings saved", "success");
|
||||
}
|
||||
setAutoSaveError(null);
|
||||
setJustSaved(true);
|
||||
// Auto-hide the saved indicator after 3 seconds
|
||||
if (justSavedTimeoutRef.current) {
|
||||
clearTimeout(justSavedTimeoutRef.current);
|
||||
}
|
||||
justSavedTimeoutRef.current = setTimeout(() => setJustSaved(false), 3000);
|
||||
await onSaved();
|
||||
return true;
|
||||
} catch (err) {
|
||||
addToast(`Failed to save settings: ${getErrorMessage(err)}`, "error");
|
||||
if (revision === saveRevisionRef.current) {
|
||||
const message = getErrorMessage(err);
|
||||
setAutoSaveError(message);
|
||||
addToast(`Failed to save settings: ${message}`, "error");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
if (revision === saveRevisionRef.current) {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
}, [addToast, agent.id, buildSavePayload, onSaved, projectId, validationErrors]);
|
||||
|
||||
const handleSave = async () => {
|
||||
await persistSettings(true, "manual");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasChanges || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
void persistSettings(false, "auto");
|
||||
}, CONFIG_AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [hasChanges, isSaving, persistSettings, validationErrors]);
|
||||
|
||||
const saveStatusLabel = isSaving
|
||||
? "Saving changes…"
|
||||
: autoSaveError
|
||||
? `Save failed: ${autoSaveError}`
|
||||
: !hasChanges && justSaved
|
||||
? "All changes saved"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="config-tab">
|
||||
<div className="config-section">
|
||||
@@ -3954,10 +4024,10 @@ function ConfigTab({
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!hasChanges && justSaved && (
|
||||
<span className="config-saved-indicator">
|
||||
<CheckCircle size={14} />
|
||||
Settings saved
|
||||
{saveStatusLabel && (
|
||||
<span className={cn("config-saved-indicator", autoSaveError && "config-saved-indicator--error")}>
|
||||
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle size={14} />}
|
||||
{saveStatusLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4363,4 +4363,99 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config autosave", () => {
|
||||
const openSettings = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
const settingsTab = await screen.findByRole("button", { name: "Settings" });
|
||||
await user.click(settingsTab);
|
||||
await screen.findByText("Agent Configuration");
|
||||
};
|
||||
|
||||
it("auto-saves after debounce without clicking Save Settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await openSettings(user);
|
||||
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)");
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "45");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledTimes(1);
|
||||
}, { timeout: 3000 });
|
||||
expect(mockUpdateAgent.mock.calls[0]?.[1]).toMatchObject({
|
||||
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 45_000 }),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not autosave while validation errors are present", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await openSettings(user);
|
||||
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)");
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "abc");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('"Heartbeat Interval" must be a valid number')).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledTimes(0);
|
||||
}, { timeout: 900 });
|
||||
});
|
||||
|
||||
it("shows saving then saved indicator during autosave", async () => {
|
||||
const initialAgent = createMockAgent();
|
||||
const refreshedAgent = createMockAgent({
|
||||
runtimeConfig: { ...(initialAgent.runtimeConfig ?? {}), heartbeatTimeoutMs: 90_000 },
|
||||
updatedAt: "2024-01-01T00:10:00.000Z",
|
||||
});
|
||||
mockFetchAgent.mockReset();
|
||||
mockFetchAgent.mockResolvedValueOnce(initialAgent).mockResolvedValue(refreshedAgent);
|
||||
|
||||
let resolveSave: (() => void) | null = null;
|
||||
mockUpdateAgent.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveSave = () => resolve(createMockAgent() as any);
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await openSettings(user);
|
||||
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Timeout (s)");
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "90");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Saving changes…")).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
resolveSave?.();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All changes saved")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("debounces rapid edits into a single autosave using latest value", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await openSettings(user);
|
||||
|
||||
const heartbeatInput = screen.getByLabelText("Heartbeat Interval (s)");
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "1");
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "12");
|
||||
await user.clear(heartbeatInput);
|
||||
await user.type(heartbeatInput, "123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledTimes(1);
|
||||
}, { timeout: 4000 });
|
||||
expect(mockUpdateAgent.mock.calls[0]?.[1]).toMatchObject({
|
||||
runtimeConfig: expect.objectContaining({ heartbeatIntervalMs: 123_000 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user