fix(ui): keep changed-entry instruction edits readable

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 21:38:40 -05:00
1 parent 19d3f4d78f
commit 8e3391599b
2 files changed
+59 -2

No files matched your search

@@ -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<AgentInstructionsFileDetail["revision"]>;
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<HTMLElement>('[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<HTMLButtonElement>('[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<HTMLTextAreaElement>('[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<AgentInstructionsFileDetail["revision"]>;
+21 -2
View File
@@ -2201,6 +2201,7 @@ export function PromptsTab({
const [draft, setDraft] = useState<string | null>(null);
const draftBaseRevisionRef = useRef<string | null | undefined>(undefined);
const [candidateRunId, setCandidateRunId] = useState<string | null>(null);
const [readOnlyCandidateRunId, setReadOnlyCandidateRunId] = useState<string | null>(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({
<span className="font-mono text-xs text-muted-foreground">{candidate.runId.slice(0, 8)}</span>
<span className="text-sm text-muted-foreground">{candidate.entryFile} · {formatDate(candidate.createdAt)}</span>
<Button type="button" variant="outline" size="sm"
disabled={isDirty || isSaving || candidate.content === null || candidate.entryFile !== currentEntryFile}
onClick={() => loadCandidate.mutate(candidate)}>Review preserved edits</Button>
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</Button>
{candidate.errorMessage && <p className="text-sm text-muted-foreground">{candidate.errorMessage}</p>}
{candidate.entryFile !== currentEntryFile && <p className="text-sm text-muted-foreground">The instruction entry changed. These edits remain preserved for the original file.</p>}
{candidate.entryFile !== currentEntryFile && candidate.content !== null && readOnlyCandidateRunId === candidate.runId && (
<div role="region" aria-label={`Preserved edits for ${candidate.entryFile}`} className="w-full space-y-3">
<p className="text-sm text-muted-foreground">Read only: {candidate.entryFile}. To keep any of these edits in {currentEntryFile}, copy them and edit the current entry explicitly.</p>
<CopyText text={candidate.content} ariaLabel={`Copy preserved edits for ${candidate.entryFile}`}
className="inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground">
<Copy className="h-3.5 w-3.5" />Copy preserved edits
</CopyText>
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted p-3 font-mono text-sm">{candidate.content}</pre>
</div>
)}
</div>
))}
</div>