Skip to content

Commit c9d0464

Browse files
clawsweeper[bot]BlackFrameAITakhoffman
authored
fix(control-ui): support raw edits from editable config (#86726)
Summary: - Merged fix(control-ui): support raw edits from editable config after ClawSweeper review. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(control-ui): support raw edits from editable config Validation: - ClawSweeper review passed for head befbe16. - Required merge gates passed before the squash merge. Prepared head SHA: befbe16 Review: #86726 (comment) Co-authored-by: BlackFrameAI <122847831+BlackFrameAI@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 5a33378 commit c9d0464

3 files changed

Lines changed: 94 additions & 11 deletions

File tree

ui/src/ui/app-render.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
saveConfig,
5757
stageDefaultAgentConfigEntry,
5858
stageConfigPreset,
59+
updateConfigRawValue,
5960
updateConfigFormValue,
6061
removeConfigFormValue,
6162
} from "./controllers/config.ts";
@@ -1163,7 +1164,7 @@ export function renderApp(state: AppViewState) {
11631164
formValue: state.configForm,
11641165
originalValue: state.configFormOriginal,
11651166
onRawChange: (next: string) => {
1166-
state.configRaw = next;
1167+
updateConfigRawValue(state, next);
11671168
},
11681169
onRequestUpdate: requestHostUpdate,
11691170
onFormPatch: (path: Array<string | number>, value: unknown) =>
@@ -1198,7 +1199,10 @@ export function renderApp(state: AppViewState) {
11981199
gatewayUrl: state.settings.gatewayUrl,
11991200
assistantName: state.assistantName,
12001201
configPath: state.configSnapshot?.path ?? null,
1201-
rawAvailable: typeof state.configSnapshot?.raw === "string",
1202+
rawAvailable:
1203+
typeof state.configSnapshot?.raw === "string" ||
1204+
!!state.configSnapshot?.config ||
1205+
!!state.configForm,
12021206
} satisfies Omit<
12031207
ConfigProps,
12041208
| "formMode"

ui/src/ui/controllers/config.test.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
stageDefaultAgentConfigEntry,
1212
stageConfigPreset,
1313
updateConfigFormValue,
14+
updateConfigRawValue,
1415
type ConfigState,
1516
} from "./config.ts";
1617

@@ -183,7 +184,7 @@ describe("applyConfigSnapshot", () => {
183184
expect(state.configDraftBaseHash).toBe("hash-remote");
184185
});
185186

186-
it("forces form mode when the snapshot does not include raw text", () => {
187+
it("keeps raw mode when editable config can be serialized without raw text", () => {
187188
const state = createState();
188189
state.configFormMode = "raw";
189190

@@ -195,9 +196,57 @@ describe("applyConfigSnapshot", () => {
195196
raw: null,
196197
});
197198

198-
expect(state.configFormMode).toBe("form");
199+
expect(state.configFormMode).toBe("raw");
199200
expect(state.configRaw).toBe('{\n "gateway": {\n "mode": "local"\n }\n}\n');
200201
});
202+
203+
it("does not clobber raw edits while dirty", () => {
204+
const state = createState();
205+
state.configFormMode = "raw";
206+
applyConfigSnapshot(state, {
207+
hash: "hash-original",
208+
config: { gateway: { mode: "local" } },
209+
valid: true,
210+
issues: [],
211+
raw: '{\n "gateway": { "mode": "local" }\n}\n',
212+
});
213+
214+
updateConfigRawValue(state, '{\n "gateway": { "mode": "remote" }\n}\n');
215+
applyConfigSnapshot(state, {
216+
hash: "hash-refreshed",
217+
config: { gateway: { mode: "external" } },
218+
valid: true,
219+
issues: [],
220+
raw: '{\n "gateway": { "mode": "external" }\n}\n',
221+
});
222+
223+
expect(state.configSnapshot?.hash).toBe("hash-refreshed");
224+
expect(state.configDraftBaseHash).toBe("hash-original");
225+
expect(state.configRaw).toBe('{\n "gateway": { "mode": "remote" }\n}\n');
226+
});
227+
});
228+
229+
describe("updateConfigRawValue", () => {
230+
it("tracks raw edits as pending changes", () => {
231+
const state = createState();
232+
applyConfigSnapshot(state, {
233+
hash: "hash-original",
234+
config: { gateway: { mode: "local" } },
235+
valid: true,
236+
issues: [],
237+
raw: '{\n "gateway": { "mode": "local" }\n}\n',
238+
});
239+
240+
updateConfigRawValue(state, '{\n "gateway": { "mode": "remote" }\n}\n');
241+
242+
expect(state.configFormDirty).toBe(true);
243+
expect(state.configDraftBaseHash).toBe("hash-original");
244+
245+
updateConfigRawValue(state, '{\n "gateway": { "mode": "local" }\n}\n');
246+
247+
expect(state.configFormDirty).toBe(false);
248+
expect(state.configDraftBaseHash).toBe("hash-original");
249+
});
201250
});
202251

203252
describe("loadConfig", () => {
@@ -707,6 +756,29 @@ describe("applyConfig", () => {
707756
});
708757

709758
describe("saveConfig", () => {
759+
it("submits generated raw text when the snapshot did not include raw text", async () => {
760+
const request = createRequestWithConfigGet();
761+
const state = createState();
762+
state.connected = true;
763+
state.client = { request } as unknown as ConfigState["client"];
764+
state.configFormMode = "raw";
765+
applyConfigSnapshot(state, {
766+
hash: "hash-generated-raw",
767+
sourceConfig: { gateway: { mode: "local" } },
768+
config: { gateway: { mode: "local", runtimeOnly: true } },
769+
valid: true,
770+
issues: [],
771+
raw: null,
772+
});
773+
774+
await saveConfig(state);
775+
776+
expect(request).toHaveBeenCalledWith("config.set", {
777+
raw: '{\n "gateway": {\n "mode": "local"\n }\n}\n',
778+
baseHash: "hash-generated-raw",
779+
});
780+
});
781+
710782
it("submits the original draft base hash after a dirty config refresh", async () => {
711783
const request = createRequestWithConfigGet();
712784
const state = createState();

ui/src/ui/controllers/config.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export function applyConfigSnapshot(
113113
const draftBaseHash = state.configDraftBaseHash ?? state.configSnapshot?.hash ?? null;
114114
state.configSnapshot = snapshot;
115115
const editableConfig = resolveEditableSnapshotConfig(snapshot);
116-
const rawAvailable = typeof snapshot.raw === "string";
116+
const rawAvailable = typeof snapshot.raw === "string" || !!editableConfig || !!state.configForm;
117117
if (!rawAvailable && state.configFormMode === "raw") {
118118
state.configFormMode = "form";
119119
}
@@ -123,11 +123,11 @@ export function applyConfigSnapshot(
123123
: editableConfig
124124
? serializeConfigForm(editableConfig)
125125
: state.configRaw;
126-
if (!preservePendingChanges || state.configFormMode === "raw") {
126+
if (!preservePendingChanges) {
127127
state.configRaw = rawFromSnapshot;
128-
} else if (state.configForm) {
128+
} else if (state.configFormMode !== "raw" && state.configForm) {
129129
state.configRaw = serializeConfigForm(state.configForm);
130-
} else {
130+
} else if (state.configFormMode !== "raw") {
131131
state.configRaw = rawFromSnapshot;
132132
}
133133
state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null;
@@ -161,9 +161,6 @@ function asJsonSchema(value: unknown): JsonSchema | null {
161161
* gateway's Zod validation always sees correctly typed values.
162162
*/
163163
function serializeFormForSubmit(state: ConfigState): string {
164-
if (state.configFormMode === "raw" && typeof state.configSnapshot?.raw !== "string") {
165-
throw new Error("Raw config editing is unavailable for this snapshot. Switch to Form mode.");
166-
}
167164
if (state.configFormMode !== "form" || !state.configForm) {
168165
return state.configRaw;
169166
}
@@ -393,6 +390,16 @@ export function updateConfigFormValue(
393390
});
394391
}
395392

393+
export function updateConfigRawValue(state: ConfigState, value: string) {
394+
state.configRaw = value;
395+
state.configFormDirty = value !== state.configRawOriginal;
396+
if (state.configFormDirty) {
397+
state.configDraftBaseHash = state.configDraftBaseHash ?? state.configSnapshot?.hash ?? null;
398+
} else {
399+
state.configDraftBaseHash = state.configSnapshot?.hash ?? null;
400+
}
401+
}
402+
396403
export function stageConfigPreset(state: ConfigState, patch: Record<string, unknown>) {
397404
const snapshotConfig = resolveEditableSnapshotConfig(state.configSnapshot);
398405
const baseSource = state.configForm ?? snapshotConfig;

0 commit comments

Comments
 (0)