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:
Fusion
2026-05-04 13:48:47 -07:00
committed by gsxdsm
parent cfdc72e511
commit bf5a0208a9
4 changed files with 208 additions and 37 deletions

View File

@@ -916,6 +916,10 @@
color: var(--color-success);
}
.config-saved-indicator--error {
color: var(--color-error);
}
.heartbeat-procedure-actions {
margin-top: var(--space-sm);
}

View File

@@ -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>

View File

@@ -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 }),
});
});
});
});