fix(ui): guard environment variable drafts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-27 15:43:24 +00:00
1 parent 0fcda52537
commit c5d4d47f63
3 files changed
+69 -3

No files matched your search

@@ -201,6 +201,8 @@ async function renderForm(
showAdapterTestEnvironmentButton?: boolean;
configurationShell?: boolean;
visibleConfigurationSections?: ReadonlySet<string>;
onDirtyChange?: (dirty: boolean) => void;
onDirtyDetailsChange?: (details: { count: number; sections: string[] }) => void;
} = {},
) {
mockEnvironmentsApi.list.mockResolvedValue(environments);
@@ -229,6 +231,8 @@ async function renderForm(
configurationShell={options.configurationShell}
visibleConfigurationSections={options.visibleConfigurationSections}
sectionLayout={options.configurationShell ? "cards" : undefined}
onDirtyChange={options.onDirtyChange}
onDirtyDetailsChange={options.onDirtyDetailsChange}
/>
</TooltipProvider>
</QueryClientProvider>,
@@ -623,6 +627,38 @@ describe("AgentConfigForm environment selector", () => {
}
});
it("reports editor-local environment drafts as unsaved configuration", async () => {
const onDirtyChange = vi.fn();
const onDirtyDetailsChange = vi.fn();
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
], {
adapterConfig: {
env: { API_TOKEN: { type: "plain", value: "old-token" } },
},
}, {
configurationShell: true,
visibleConfigurationSections: new Set(["environment"]),
onDirtyChange,
onDirtyDetailsChange,
});
roots.push(result.root);
const valueInput = result.container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]');
expect(valueInput).toBeTruthy();
await act(async () => {
setInputValue(valueInput!, "draft-token");
});
await flushReact();
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
expect(onDirtyDetailsChange).toHaveBeenLastCalledWith({
count: 1,
sections: ["environment"],
});
});
it("surfaces request failures instead of converting them into model test checks", async () => {
mockAgentsApi.testEnvironment.mockRejectedValueOnce(new Error("Network unavailable"));
+17 -2
View File
@@ -338,6 +338,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// ---- Edit mode: overlay for dirty tracking ----
const [overlay, setOverlay] = useState<AgentConfigOverlay>(emptyOverlay);
const [environmentDraftDirty, setEnvironmentDraftDirty] = useState(false);
const agentRef = useRef<Agent | null>(null);
// Clear overlay when agent data refreshes (after save)
@@ -350,8 +351,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
const isDirty = !isCreate && isOverlayDirty(overlay);
const dirtyDetails = useMemo(() => getAgentConfigDirtyDetails(overlay), [overlay]);
const overlayDirty = !isCreate && isOverlayDirty(overlay);
const isDirty = overlayDirty || environmentDraftDirty;
const dirtyDetails = useMemo(() => {
const details = getAgentConfigDirtyDetails(overlay);
if (!environmentDraftDirty || "env" in overlay.adapterConfig) return details;
return {
count: details.count + 1,
sections: details.sections.includes("environment")
? details.sections
: [...details.sections, "environment"],
};
}, [environmentDraftDirty, overlay]);
const changes = useMemo(
() => isCreate ? [] : buildAgentConfigChanges(props.agent, overlay),
[isCreate, overlay, !isCreate ? props.agent : undefined], // eslint-disable-line react-hooks/exhaustive-deps
@@ -406,6 +417,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
/** Build accumulated patch and send to parent */
const handleCancel = useCallback(() => {
environmentVariablesEditorRef.current?.discardPendingDraft();
setEnvironmentDraftDirty(false);
setOverlay({ ...emptyOverlay });
}, []);
@@ -1446,6 +1459,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? set!({ envBindings: env ?? {}, envVars: "" })
: mark("adapterConfig", "env", env)
}
onDirtyChange={isCreate ? undefined : setEnvironmentDraftDirty}
/>
</Field>}
@@ -1631,6 +1645,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? set!({ envBindings: env ?? {}, envVars: "" })
: mark("adapterConfig", "env", env)
}
onDirtyChange={isCreate ? undefined : setEnvironmentDraftDirty}
/>
{!isCreate && (
<Field label="Secret access" hint={help.secretAccess}>
@@ -143,6 +143,8 @@ export interface EnvironmentVariablesEditorHandle {
* action reads parent state. Returns the promoted value when a draft existed.
*/
flushPendingDraft: () => Record<string, EnvBinding> | null;
/** Discard the editor-local draft and restore the controlled value. */
discardPendingDraft: () => void;
}
export const EnvironmentVariablesEditor = forwardRef<EnvironmentVariablesEditorHandle, EnvironmentVariablesEditorProps>(function EnvironmentVariablesEditor({
@@ -288,7 +290,20 @@ export const EnvironmentVariablesEditor = forwardRef<EnvironmentVariablesEditorH
return draftValue ?? {};
}, [disabled, draftValue, draftValueKey, hasUnsavedChanges, onChange]);
useImperativeHandle(ref, () => ({ flushPendingDraft }), [flushPendingDraft]);
const discardPendingDraft = useCallback(() => {
if (disabled || !hasUnsavedChanges) return;
pendingSaveValueKeyRef.current = null;
const nextRows = rowsFromValue(value);
setRows(nextRows);
touchCommittedNames(nextRows);
markCommitted(normalizedEnvKey(value), nextRows);
}, [disabled, hasUnsavedChanges, value]);
useImperativeHandle(
ref,
() => ({ flushPendingDraft, discardPendingDraft }),
[discardPendingDraft, flushPendingDraft],
);
useEffect(() => {
const form = editorRootRef.current?.closest("form");