diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 760012f0666..6d6969bd393 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -201,6 +201,8 @@ async function renderForm( showAdapterTestEnvironmentButton?: boolean; configurationShell?: boolean; visibleConfigurationSections?: ReadonlySet; + 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} /> , @@ -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('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")); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index d08adf6ef12..fc6dec88364 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -338,6 +338,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // ---- Edit mode: overlay for dirty tracking ---- const [overlay, setOverlay] = useState(emptyOverlay); + const [environmentDraftDirty, setEnvironmentDraftDirty] = useState(false); const agentRef = useRef(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} /> } @@ -1631,6 +1645,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? set!({ envBindings: env ?? {}, envVars: "" }) : mark("adapterConfig", "env", env) } + onDirtyChange={isCreate ? undefined : setEnvironmentDraftDirty} /> {!isCreate && ( diff --git a/ui/src/components/environment-variables-editor/index.tsx b/ui/src/components/environment-variables-editor/index.tsx index d29f59c9303..39cf57807f3 100644 --- a/ui/src/components/environment-variables-editor/index.tsx +++ b/ui/src/components/environment-variables-editor/index.tsx @@ -143,6 +143,8 @@ export interface EnvironmentVariablesEditorHandle { * action reads parent state. Returns the promoted value when a draft existed. */ flushPendingDraft: () => Record | null; + /** Discard the editor-local draft and restore the controlled value. */ + discardPendingDraft: () => void; } export const EnvironmentVariablesEditor = forwardRef(function EnvironmentVariablesEditor({ @@ -288,7 +290,20 @@ export const EnvironmentVariablesEditor = forwardRef ({ 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");