diff --git a/ui/src/pages/AgentDetail.instructions.test.tsx b/ui/src/pages/AgentDetail.instructions.test.tsx index bef634089e1..04c60877a5e 100644 --- a/ui/src/pages/AgentDetail.instructions.test.tsx +++ b/ui/src/pages/AgentDetail.instructions.test.tsx @@ -22,6 +22,9 @@ const mockAgentsApi = vi.hoisted(() => ({ })); const markdownEditorRenderMock = vi.hoisted(() => vi.fn()); +const copyTextToClipboardMock = vi.hoisted(() => vi.fn(async (_text: string) => {})); + +vi.mock("../lib/clipboard", () => ({ copyTextToClipboard: copyTextToClipboardMock })); vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi, @@ -380,6 +383,41 @@ describe("PromptsTab instruction editor", () => { expect(mockAgentsApi.resolveInstructionCandidate.mock.lastCall?.[2].baseRevisionId).toBe("head-3"); }); + it("keeps changed-entry preserved edits readable and copyable without enabling a save", async () => { + const summary = makeSummary("CURRENT.md", "CURRENT.md"); + const revision = { id: "current-head", entryFile: "CURRENT.md" } as NonNullable; + const preserved = "# Original entry\nPreserve these exact edits.\n"; + mockAgentsApi.instructionCandidates.mockResolvedValue([{ runId: "old-entry-run", entryFile: "OLD.md", baseRevisionId: "old-head", baseHash: "base-hash", state: "conflict", candidateHash: "candidate-hash", content: preserved, errorCode: "INSTRUCTION_ENTRY_CHANGED", errorMessage: "The instruction entry changed", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }]); + await renderPromptsTab(makeBundle("CURRENT.md", [summary]), { "CURRENT.md": makeDetail(summary, "Current instructions", { revision }) }); + await waitFor(() => expect(buttonByText(container, "Review preserved edits").disabled).toBe(false)); + await act(async () => { buttonByText(container, "Review preserved edits").click(); }); + const review = await waitFor(() => { + const region = container.querySelector('[aria-label="Preserved edits for OLD.md"]'); + expect(region?.querySelector("pre")?.textContent).toBe(preserved); + return region!; + }); + expect(review.textContent).toContain("Read only"); + expect(review.textContent).toContain("CURRENT.md"); + expect(review.querySelector("textarea, input, [contenteditable=true]")).toBeNull(); + expect(saveAction).toBeNull(); + expect(mockAgentsApi.instructionsFile.mock.calls.every((call) => call[1] === "CURRENT.md")).toBe(true); + await act(async () => { review.querySelector('[aria-label="Copy preserved edits for OLD.md"]')!.click(); }); + expect(copyTextToClipboardMock).toHaveBeenCalledWith(preserved); + expect(saveAction).toBeNull(); + expect(mockAgentsApi.resolveInstructionCandidate).not.toHaveBeenCalled(); + expect(mockAgentsApi.saveInstructionsFile).not.toHaveBeenCalled(); + + // Choosing to edit and paste into the current entry is a separate ordinary save. + await selectInstructionMode("Edit"); + const editor = container.querySelector('[data-testid="markdown-editor"]')!; + expect(editor.value).toBe("Current instructions"); + await act(async () => { editor.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); setNativeValue(editor, preserved); }); + await waitFor(() => expect(saveAction).toEqual(expect.any(Function))); + await act(async () => { saveAction?.(); }); + await waitFor(() => expect(mockAgentsApi.saveInstructionsFile).toHaveBeenCalledWith("agent-1", { path: "CURRENT.md", content: preserved, baseRevisionId: "current-head", clearLegacyPromptTemplate: false }, "company-1")); + expect(mockAgentsApi.resolveInstructionCandidate).not.toHaveBeenCalled(); + }); + it("retains the draft and its base when a concurrent save conflicts", async () => { const summary = makeSummary("AGENTS.md", "AGENTS.md"); const revision = { id: "base-1", entryFile: "AGENTS.md" } as NonNullable; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 3f2f739bc44..7e44600f472 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -2201,6 +2201,7 @@ export function PromptsTab({ const [draft, setDraft] = useState(null); const draftBaseRevisionRef = useRef(undefined); const [candidateRunId, setCandidateRunId] = useState(null); + const [readOnlyCandidateRunId, setReadOnlyCandidateRunId] = useState(null); const candidateAgentRef = useRef(agent.id); candidateAgentRef.current = agent.id; const [bundleDraft, setBundleDraft] = useState<{ @@ -2242,6 +2243,7 @@ export function PromptsTab({ setInstructionMode("read"); setShowFilePanel(false); setDraft(null); + setReadOnlyCandidateRunId(null); setBundleDraft(null); setNewFilePath(""); setShowNewFileInput(false); @@ -3022,10 +3024,27 @@ export function PromptsTab({ {candidate.runId.slice(0, 8)} {candidate.entryFile} ยท {formatDate(candidate.createdAt)} + disabled={candidate.content === null || (candidate.entryFile === currentEntryFile && (isDirty || isSaving))} + aria-expanded={candidate.entryFile !== currentEntryFile ? readOnlyCandidateRunId === candidate.runId : undefined} + onClick={() => { + if (candidate.entryFile !== currentEntryFile) { + setReadOnlyCandidateRunId((current) => current === candidate.runId ? null : candidate.runId); + } else { + loadCandidate.mutate(candidate); + } + }}>Review preserved edits {candidate.errorMessage &&

{candidate.errorMessage}

} {candidate.entryFile !== currentEntryFile &&

The instruction entry changed. These edits remain preserved for the original file.

} + {candidate.entryFile !== currentEntryFile && candidate.content !== null && readOnlyCandidateRunId === candidate.runId && ( +
+

Read only: {candidate.entryFile}. To keep any of these edits in {currentEntryFile}, copy them and edit the current entry explicitly.

+ + Copy preserved edits + +
{candidate.content}
+
+ )} ))}