diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 2955d0c411d..b1a3329e195 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -42,6 +42,7 @@ import { findAgentConfigEntryIndex, loadConfig, openConfigFile, + resetConfigPendingChanges, runUpdate, saveConfig, updateConfigFormValue, @@ -856,6 +857,7 @@ export function renderApp(state: AppViewState) { onFormPatch: (path: Array, value: unknown) => updateConfigFormValue(state, path, value), onReload: () => loadConfig(state), + onReset: () => resetConfigPendingChanges(state), onSave: () => saveConfig(state), onApply: () => applyConfig(state), onUpdate: () => runUpdate(state), diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 513dd69d251..a6b75194dec 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -4,6 +4,7 @@ import { applyConfig, ensureAgentConfigEntry, findAgentConfigEntryIndex, + resetConfigPendingChanges, runUpdate, saveConfig, updateConfigFormValue, @@ -163,6 +164,29 @@ describe("updateConfigFormValue", () => { }); }); +describe("resetConfigPendingChanges", () => { + it("restores the original form and raw config snapshot", () => { + const state = createState(); + state.configSnapshot = { + config: { gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: '{\n "gateway": { "mode": "local" }\n}\n', + }; + state.configFormOriginal = { gateway: { mode: "local" } }; + state.configRawOriginal = '{\n "gateway": { "mode": "local" }\n}\n'; + state.configForm = { gateway: { mode: "remote", port: 3000 } }; + state.configRaw = '{\n "gateway": { "mode": "remote", "port": 3000 }\n}\n'; + state.configFormDirty = true; + + resetConfigPendingChanges(state); + + expect(state.configFormDirty).toBe(false); + expect(state.configForm).toEqual({ gateway: { mode: "local" } }); + expect(state.configRaw).toBe('{\n "gateway": { "mode": "local" }\n}\n'); + }); +}); + describe("agent config helpers", () => { it("finds explicit agent entries", () => { expect( diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index b8735a210e5..f708ef09081 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -218,6 +218,16 @@ export function updateConfigFormValue( mutateConfigForm(state, (draft) => setPathValue(draft, path, value)); } +export function resetConfigPendingChanges(state: ConfigState) { + state.configForm = cloneConfigObject( + state.configFormOriginal ?? state.configSnapshot?.config ?? {}, + ); + state.configRaw = + state.configRawOriginal || + serializeConfigForm(state.configFormOriginal ?? state.configSnapshot?.config ?? {}); + state.configFormDirty = false; +} + export function removeConfigFormValue(state: ConfigState, path: Array) { mutateConfigForm(state, (draft) => removePathValue(draft, path)); } diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 63d88830ed2..f79eae6cc85 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -33,6 +33,7 @@ describe("config view", () => { onSearchChange: vi.fn(), onSectionChange: vi.fn(), onReload: vi.fn(), + onReset: vi.fn(), onSave: vi.fn(), onApply: vi.fn(), onUpdate: vi.fn(), @@ -49,11 +50,13 @@ describe("config view", () => { }); function findActionButtons(container: HTMLElement): { + clearButton?: HTMLButtonElement; saveButton?: HTMLButtonElement; applyButton?: HTMLButtonElement; } { const buttons = Array.from(container.querySelectorAll("button")); return { + clearButton: buttons.find((btn) => btn.textContent?.trim() === "Clear pending updates"), saveButton: buttons.find((btn) => btn.textContent?.trim() === "Save"), applyButton: buttons.find((btn) => btn.textContent?.trim() === "Apply"), }; @@ -129,22 +132,30 @@ describe("config view", () => { raw: "{\n}\n", originalRaw: "{\n}\n", }); - ({ saveButton, applyButton } = findActionButtons(container)); + let { clearButton, saveButton, applyButton } = findActionButtons(container); + expect(clearButton).not.toBeUndefined(); expect(saveButton).not.toBeUndefined(); expect(applyButton).not.toBeUndefined(); + expect(clearButton?.disabled).toBe(true); expect(saveButton?.disabled).toBe(true); expect(applyButton?.disabled).toBe(true); + const onReset = vi.fn(); renderCase({ formMode: "raw", raw: '{\n gateway: { mode: "local" }\n}\n', originalRaw: "{\n}\n", + onReset, }); - ({ saveButton, applyButton } = findActionButtons(container)); + ({ clearButton, saveButton, applyButton } = findActionButtons(container)); expect(saveButton).not.toBeUndefined(); expect(applyButton).not.toBeUndefined(); + expect(clearButton?.disabled).toBe(false); expect(saveButton?.disabled).toBe(false); expect(applyButton?.disabled).toBe(false); + + clearButton?.click(); + expect(onReset).toHaveBeenCalledTimes(1); }); it("switches mode via the sidebar toggle", () => { diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 72d2981f126..62befcaf7cf 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -52,6 +52,7 @@ export type ConfigProps = { onSectionChange: (section: string | null) => void; onSubsectionChange: (section: string | null) => void; onReload: () => void; + onReset: () => void; onSave: () => void; onApply: () => void; onUpdate: () => void; @@ -959,6 +960,9 @@ export function renderConfig(props: ConfigProps) { +