From befbe163626b9cf69a840ffb09c86d1828d4e915 Mon Sep 17 00:00:00 2001 From: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 02:24:32 +0000 Subject: [PATCH] fix(control-ui): support raw edits from editable config --- ui/src/ui/app-render.ts | 3 +- ui/src/ui/controllers/config.test.ts | 49 ++++++++++++++++++++++++++++ ui/src/ui/controllers/config.ts | 16 +++++++-- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 8453b4f64e6..c20b0803943 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -56,6 +56,7 @@ import { saveConfig, stageDefaultAgentConfigEntry, stageConfigPreset, + updateConfigRawValue, updateConfigFormValue, removeConfigFormValue, } from "./controllers/config.ts"; @@ -1163,7 +1164,7 @@ export function renderApp(state: AppViewState) { formValue: state.configForm, originalValue: state.configFormOriginal, onRawChange: (next: string) => { - state.configRaw = next; + updateConfigRawValue(state, next); }, onRequestUpdate: requestHostUpdate, onFormPatch: (path: Array, value: unknown) => diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index a204672b2cf..400436bfca8 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -11,6 +11,7 @@ import { stageDefaultAgentConfigEntry, stageConfigPreset, updateConfigFormValue, + updateConfigRawValue, type ConfigState, } from "./config.ts"; @@ -198,6 +199,54 @@ describe("applyConfigSnapshot", () => { expect(state.configFormMode).toBe("raw"); expect(state.configRaw).toBe('{\n "gateway": {\n "mode": "local"\n }\n}\n'); }); + + it("does not clobber raw edits while dirty", () => { + const state = createState(); + state.configFormMode = "raw"; + applyConfigSnapshot(state, { + hash: "hash-original", + config: { gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: '{\n "gateway": { "mode": "local" }\n}\n', + }); + + updateConfigRawValue(state, '{\n "gateway": { "mode": "remote" }\n}\n'); + applyConfigSnapshot(state, { + hash: "hash-refreshed", + config: { gateway: { mode: "external" } }, + valid: true, + issues: [], + raw: '{\n "gateway": { "mode": "external" }\n}\n', + }); + + expect(state.configSnapshot?.hash).toBe("hash-refreshed"); + expect(state.configDraftBaseHash).toBe("hash-original"); + expect(state.configRaw).toBe('{\n "gateway": { "mode": "remote" }\n}\n'); + }); +}); + +describe("updateConfigRawValue", () => { + it("tracks raw edits as pending changes", () => { + const state = createState(); + applyConfigSnapshot(state, { + hash: "hash-original", + config: { gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: '{\n "gateway": { "mode": "local" }\n}\n', + }); + + updateConfigRawValue(state, '{\n "gateway": { "mode": "remote" }\n}\n'); + + expect(state.configFormDirty).toBe(true); + expect(state.configDraftBaseHash).toBe("hash-original"); + + updateConfigRawValue(state, '{\n "gateway": { "mode": "local" }\n}\n'); + + expect(state.configFormDirty).toBe(false); + expect(state.configDraftBaseHash).toBe("hash-original"); + }); }); describe("loadConfig", () => { diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 6ea358194a3..cb220a9b8bb 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -123,11 +123,11 @@ export function applyConfigSnapshot( : editableConfig ? serializeConfigForm(editableConfig) : state.configRaw; - if (!preservePendingChanges || state.configFormMode === "raw") { + if (!preservePendingChanges) { state.configRaw = rawFromSnapshot; - } else if (state.configForm) { + } else if (state.configFormMode !== "raw" && state.configForm) { state.configRaw = serializeConfigForm(state.configForm); - } else { + } else if (state.configFormMode !== "raw") { state.configRaw = rawFromSnapshot; } state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null; @@ -390,6 +390,16 @@ export function updateConfigFormValue( }); } +export function updateConfigRawValue(state: ConfigState, value: string) { + state.configRaw = value; + state.configFormDirty = value !== state.configRawOriginal; + if (state.configFormDirty) { + state.configDraftBaseHash = state.configDraftBaseHash ?? state.configSnapshot?.hash ?? null; + } else { + state.configDraftBaseHash = state.configSnapshot?.hash ?? null; + } +} + export function stageConfigPreset(state: ConfigState, patch: Record) { const snapshotConfig = resolveEditableSnapshotConfig(state.configSnapshot); const baseSource = state.configForm ?? snapshotConfig;