mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix(ui): keep changed-entry instruction edits readable
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
45e2496a8b
commit
98c4425e5b
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"]>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user