diff --git a/ui/src/pages/AgentDetail.instructions.test.tsx b/ui/src/pages/AgentDetail.instructions.test.tsx index a255227770..3f0dbfbf13 100644 --- a/ui/src/pages/AgentDetail.instructions.test.tsx +++ b/ui/src/pages/AgentDetail.instructions.test.tsx @@ -62,6 +62,7 @@ vi.mock("../components/MarkdownEditor", () => ({ }) => { markdownEditorRenderMock({ value, + onChange, contentClassName, hasImageUploadHandler: Boolean(imageUploadHandler), }); @@ -301,6 +302,7 @@ describe("PromptsTab instruction editor", () => { })); await act(async () => { + editor.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); setNativeValue(editor, "# Updated"); }); await waitFor(() => { @@ -321,6 +323,75 @@ describe("PromptsTab instruction editor", () => { }); }); + it("ignores rich-editor mount normalization until the user interacts", async () => { + const summary = makeSummary("AGENTS.md", "AGENTS.md"); + const onDirtyChange = vi.fn(); + await renderPromptsTab( + makeBundle("AGENTS.md", [summary]), + { "AGENTS.md": makeDetail(summary, "# Current") }, + { onDirtyChange }, + ); + + const editorProps = await waitFor(() => { + const latest = markdownEditorRenderMock.mock.calls.at(-1)?.[0] as + | { onChange?: (value: string) => void } + | undefined; + expect(latest?.onChange).toEqual(expect.any(Function)); + return latest!; + }); + + await act(async () => { + editorProps.onChange?.("# Current\n"); + }); + await flushReact(); + + expect(onDirtyChange).not.toHaveBeenCalledWith(true); + expect(saveAction).toBeNull(); + expect(mockAgentsApi.saveInstructionsFile).not.toHaveBeenCalled(); + }); + + it("releases dirty state and save controls when the instructions tab unmounts", async () => { + const summary = makeSummary("AGENTS.md", "AGENTS.md"); + const onDirtyChange = vi.fn(); + const onSavingChange = vi.fn(); + let cancelAction: (() => void) | null = null; + await renderPromptsTab( + makeBundle("AGENTS.md", [summary]), + { "AGENTS.md": makeDetail(summary, "# Current") }, + { + onDirtyChange, + onSavingChange, + onSaveActionChange: (next) => { saveAction = next; }, + onCancelActionChange: (next) => { cancelAction = next; }, + }, + ); + + const editor = await waitFor(() => { + const candidate = container.querySelector('[data-testid="markdown-editor"]'); + expect(candidate).not.toBeNull(); + return candidate!; + }); + await act(async () => { + editor.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + setNativeValue(editor, "# Updated"); + }); + await waitFor(() => { + expect(onDirtyChange).toHaveBeenLastCalledWith(true); + expect(saveAction).toEqual(expect.any(Function)); + expect(cancelAction).toEqual(expect.any(Function)); + }); + + await act(async () => { + root?.unmount(); + }); + root = null; + + expect(onDirtyChange).toHaveBeenLastCalledWith(false); + expect(onSavingChange).toHaveBeenLastCalledWith(false); + expect(saveAction).toBeNull(); + expect(cancelAction).toBeNull(); + }); + it("uses the Markdown editor for pending new .md files before server metadata exists", async () => { const summary = makeSummary("settings.json", "settings.json", { language: "json", diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 962969cb2d..213016d4ee 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -2303,7 +2303,7 @@ export function PromptsTab({ const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); const { isMobile } = useSidebar(); - const [selectedFile, setSelectedFile] = useState("AGENTS.md"); + const [selectedFile, setSelectedFileState] = useState("AGENTS.md"); const [showFilePanel, setShowFilePanel] = useState(false); const [draft, setDraft] = useState(null); const [bundleDraft, setBundleDraft] = useState<{ @@ -2325,8 +2325,20 @@ export function PromptsTab({ entryFile: string; selectedFile: string; } | null>(null); + // MDXEditor can normalize markdown and emit onChange while it mounts. Only + // treat editor output as a draft after a real interaction so merely opening + // an instructions file cannot mark the agent dirty. + const editorInteractedRef = useRef(false); + const markEditorInteracted = useCallback(() => { + editorInteractedRef.current = true; + }, []); + const setSelectedFile = useCallback((filePath: string) => { + editorInteractedRef.current = false; + setSelectedFileState(filePath); + }, []); useEffect(() => { + editorInteractedRef.current = false; setSelectedFile("AGENTS.md"); setShowFilePanel(false); setDraft(null); @@ -2393,7 +2405,10 @@ export function PromptsTab({ entryFile?: string; clearLegacyPromptTemplate?: boolean; }) => agentsApi.updateInstructionsBundle(agent.id, data, companyId), - onMutate: () => setAwaitingRefresh(true), + onMutate: () => { + editorInteractedRef.current = false; + setAwaitingRefresh(true); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); @@ -2405,7 +2420,10 @@ export function PromptsTab({ const saveFile = useMutation({ mutationFn: (data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }) => agentsApi.saveInstructionsFile(agent.id, data, companyId), - onMutate: () => setAwaitingRefresh(true), + onMutate: () => { + editorInteractedRef.current = false; + setAwaitingRefresh(true); + }, onSuccess: (_, variables) => { setPendingFiles((prev) => prev.filter((f) => f !== variables.path)); queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); @@ -2418,7 +2436,10 @@ export function PromptsTab({ const deleteFile = useMutation({ mutationFn: (relativePath: string) => agentsApi.deleteInstructionsFile(agent.id, relativePath, companyId), - onMutate: () => setAwaitingRefresh(true), + onMutate: () => { + editorInteractedRef.current = false; + setAwaitingRefresh(true); + }, onSuccess: (_, relativePath) => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); queryClient.removeQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, relativePath) }); @@ -2545,6 +2566,13 @@ export function PromptsTab({ useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]); useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]); + useEffect(() => () => { + onSaveActionChange(null); + onCancelActionChange(null); + onDirtyChange(false); + onSavingChange(false); + }, [onCancelActionChange, onDirtyChange, onSaveActionChange, onSavingChange]); + useEffect(() => { onSaveActionChange(isDirty ? () => { const save = async () => { @@ -3005,19 +3033,31 @@ export function PromptsTab({ {selectedFileExists && fileLoading && !selectedFileDetail ? ( ) : useMarkdownEditor ? ( - setDraft(value ?? "")} - placeholder="# Agent instructions" - className="min-w-0 overflow-hidden" - contentClassName="min-h-(--sz-420px) max-w-full break-words text-sm leading-7" - imageUploadHandler={async (file) => { - const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`; - const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); - return asset.contentPath; - }} - /> +
+ { + if (!editorInteractedRef.current) return; + setDraft(value ?? ""); + }} + placeholder="# Agent instructions" + className="min-w-0 overflow-hidden" + contentClassName="min-h-(--sz-420px) max-w-full break-words text-sm leading-7" + imageUploadHandler={async (file) => { + const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`; + const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); + return asset.contentPath; + }} + /> +
) : (