fix(ui): use prose editor for markdown agent instructions (#9332)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent setup depends on instruction files that are readable and
editable from the board UI
> - The instructions tab already receives server-side metadata
describing whether each file is Markdown
> - The UI was deciding Markdown editor usage primarily from the file
extension, which makes extensionless Markdown instruction files feel
like raw code
> - This pull request makes the instructions editor trust server
Markdown metadata for existing files and keep extension fallback only
for new unsaved files
> - The benefit is that AGENTS-style prose instructions render and edit
like prose while explicitly non-Markdown files still use the raw
textarea

## Linked Issues or Issue Description

- Refs #8201
- Refs #5652
- Refs #3427
- Refs #2068
- Related PRs: #2468, #2620

## What Changed

- Use server `markdown` metadata from instruction file details/summaries
to choose the prose Markdown editor for existing instruction files.
- Keep extension-based Markdown detection only for pending new files
before server metadata exists.
- Remove the monospace content styling from the Markdown editor path so
prose instructions read like normal text.
- Add focused tests for extensionless Markdown files, new `.md` files,
and `.md` files explicitly marked non-Markdown by the server.

## Verification

- `pnpm check:token-gates`
- `pnpm exec vitest run ui/src/pages/AgentDetail.instructions.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

- Low risk. The editor selection now depends on server metadata for
existing files, so incorrect server metadata would choose the wrong
editor. The fallback still preserves extension-based behavior for newly
created unsaved files.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 via Codex coding agent, tool-enabled terminal workflow.
Context window details were not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-09 19:43:44 -05:00 committed by GitHub
parent 606aa4f266
commit 9a1d4b7983
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 410 additions and 4 deletions

View File

@ -1470,7 +1470,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--hex-eab308: #eab308; /* ActivityCharts.tsx — priority 'medium'; also the 0.5-0.8 success-rate bar tint. */
--hex-6b7280: #6b7280; /* ActivityCharts.tsx — priority 'low' + unknown-status statusColors fallback. */
--hex-10b981: #10b981; /* ActivityCharts.tsx — >=0.8 success-rate bar tint; also the run-activity 'succeeded' segment. */
--hex-737373: #737373; /* ActivityCharts.tsx — run activity "other" segment tint. */
--hex-737373: #737373; /* ActivityCharts.tsx — run activity 'other' segment tint. */
--project-none: #64748b; /* Semantic rename of --hex-64748b (DECISION-SHEET.md A3, value unchanged). 'No project assigned' muted-slate fallback (TOKEN-AUDIT.md 1.3) across Routines/MarkdownEditor/RoutineRunVariablesDialog/RoutineList/IssueColumns/editable-sections; also ActivityCharts.tsx status 'backlog'. */
--project-seed: #6366f1; /* Semantic rename of --hex-6366f1 (DECISION-SHEET.md A3, value unchanged). Project-color-fallback indigo seed default (ProjectDetail/PipelineSettings/IssueProperties/NewIssueDialog) — new-project-color-picker-seed family per TOKEN-AUDIT.md 1.3. */
--liveness-blue: #2563eb; /* DECISION-SHEET.md A6: IssueChatThread.tsx human-message 'liveness blue' bubble (PAP-95 rev 5). Same value as --status-task-in_progress by coincidence, decoupled on purpose so a future status-hue change doesn't drag the chat bubble along. */

View File

@ -0,0 +1,389 @@
// @vitest-environment jsdom
import type { ComponentProps } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, AgentInstructionsBundle, AgentInstructionsFileDetail, AgentInstructionsFileSummary } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PromptsTab } from "./AgentDetail";
const mockAgentsApi = vi.hoisted(() => ({
instructionsBundle: vi.fn(),
instructionsFile: vi.fn(),
updateInstructionsBundle: vi.fn(),
saveInstructionsFile: vi.fn(),
deleteInstructionsFile: vi.fn(),
}));
const markdownEditorRenderMock = vi.hoisted(() => vi.fn());
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/assets", () => ({
assetsApi: {
uploadImage: vi.fn(async () => ({ contentPath: "/assets/uploaded-image.png" })),
},
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false }),
}));
vi.mock("@/adapters/use-adapter-capabilities", () => ({
useAdapterCapabilities: () => () => ({
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
supportsModelProfiles: true,
}),
}));
vi.mock("../components/MarkdownEditor", () => ({
MarkdownEditor: ({
value,
onChange,
placeholder,
contentClassName,
imageUploadHandler,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
contentClassName?: string;
imageUploadHandler?: (file: File) => Promise<string>;
}) => {
markdownEditorRenderMock({
value,
contentClassName,
hasImageUploadHandler: Boolean(imageUploadHandler),
});
return (
<textarea
data-testid="markdown-editor"
aria-label="Markdown editor"
className={contentClassName}
placeholder={placeholder}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function waitFor<T>(assertion: () => T): Promise<T> {
let lastError: unknown;
for (let i = 0; i < 20; i++) {
try {
return assertion();
} catch (error) {
lastError = error;
await flushReact();
}
}
throw lastError;
}
function setNativeValue(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
const prototype = element instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
setter?.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
}
function buttonByText(container: HTMLElement, text: string) {
const button = Array.from(container.querySelectorAll("button"))
.find((candidate) => candidate.textContent?.trim() === text);
if (!button) throw new Error(`Button not found: ${text}`);
return button as HTMLButtonElement;
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
urlKey: "codexcoder",
role: "engineer",
title: null,
icon: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
function makeSummary(
path: string,
entryFile: string,
overrides: Partial<AgentInstructionsFileSummary> = {},
): AgentInstructionsFileSummary {
const markdown = path.toLowerCase().endsWith(".md");
return {
path,
size: 24,
language: markdown ? "markdown" : "text",
markdown,
isEntryFile: path === entryFile,
editable: true,
deprecated: false,
virtual: false,
...overrides,
};
}
function makeDetail(
summary: AgentInstructionsFileSummary,
content = "# Agent instructions",
overrides: Partial<AgentInstructionsFileDetail> = {},
): AgentInstructionsFileDetail {
return {
...summary,
content,
...overrides,
};
}
function makeBundle(
entryFile: string,
files: AgentInstructionsFileSummary[],
overrides: Partial<AgentInstructionsBundle> = {},
): AgentInstructionsBundle {
return {
agentId: "agent-1",
companyId: "company-1",
mode: "managed",
rootPath: "/paperclip/agents/agent-1/instructions",
managedRootPath: "/paperclip/agents/agent-1/instructions",
entryFile,
resolvedEntryPath: `/paperclip/agents/agent-1/instructions/${entryFile}`,
editable: true,
warnings: [],
legacyPromptTemplateActive: false,
legacyBootstrapPromptTemplateActive: false,
files,
...overrides,
};
}
describe("PromptsTab instruction editor", () => {
let container: HTMLDivElement;
let root: Root | null;
let queryClient: QueryClient;
let saveAction: (() => void) | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
saveAction = null;
markdownEditorRenderMock.mockClear();
Object.values(mockAgentsApi).forEach((mock) => mock.mockReset());
mockAgentsApi.updateInstructionsBundle.mockResolvedValue({});
mockAgentsApi.saveInstructionsFile.mockImplementation(async (_agentId, data) => ({
path: data.path,
size: data.content.length,
language: "markdown",
markdown: true,
isEntryFile: true,
editable: true,
deprecated: false,
virtual: false,
content: data.content,
}));
mockAgentsApi.deleteInstructionsFile.mockResolvedValue({});
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
queryClient.clear();
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function renderPromptsTab(
bundle: AgentInstructionsBundle,
details: Record<string, AgentInstructionsFileDetail>,
props: Partial<ComponentProps<typeof PromptsTab>> = {},
) {
mockAgentsApi.instructionsBundle.mockResolvedValue(bundle);
mockAgentsApi.instructionsFile.mockImplementation(async (_agentId: string, path: string) => {
const detail = details[path];
if (!detail) throw new Error(`Missing detail for ${path}`);
return detail;
});
root = createRoot(container);
await act(async () => {
root?.render(
<QueryClientProvider client={queryClient}>
<PromptsTab
agent={props.agent ?? makeAgent()}
companyId={props.companyId ?? "company-1"}
onDirtyChange={props.onDirtyChange ?? vi.fn()}
onSaveActionChange={props.onSaveActionChange ?? ((next) => { saveAction = next; })}
onCancelActionChange={props.onCancelActionChange ?? vi.fn()}
onSavingChange={props.onSavingChange ?? vi.fn()}
/>
</QueryClientProvider>,
);
});
await flushReact();
}
it("uses server markdown metadata for extensionless files and saves MarkdownEditor drafts", async () => {
const summary = makeSummary("AGENTS", "AGENTS", {
language: "markdown",
markdown: true,
});
await renderPromptsTab(
makeBundle("AGENTS", [summary]),
{ AGENTS: makeDetail(summary, "# Current") },
);
const editor = await waitFor(() => {
const candidate = container.querySelector<HTMLTextAreaElement>('[data-testid="markdown-editor"]');
expect(candidate).not.toBeNull();
return candidate!;
});
expect(markdownEditorRenderMock).toHaveBeenLastCalledWith(expect.objectContaining({
contentClassName: expect.not.stringContaining("font-mono"),
hasImageUploadHandler: true,
value: "# Current",
}));
await act(async () => {
setNativeValue(editor, "# Updated");
});
await waitFor(() => {
expect(saveAction).toEqual(expect.any(Function));
});
saveAction?.();
await waitFor(() => {
expect(mockAgentsApi.saveInstructionsFile).toHaveBeenCalledWith(
"agent-1",
{
path: "AGENTS",
content: "# Updated",
clearLegacyPromptTemplate: false,
},
"company-1",
);
});
});
it("uses the Markdown editor for pending new .md files before server metadata exists", async () => {
const summary = makeSummary("settings.json", "settings.json", {
language: "json",
markdown: false,
});
await renderPromptsTab(
makeBundle("settings.json", [summary]),
{ "settings.json": makeDetail(summary, "{\n \"ok\": true\n}") },
);
await waitFor(() => {
expect(container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]')).not.toBeNull();
});
await act(async () => {
buttonByText(container, "+").click();
});
await flushReact();
const input = container.querySelector<HTMLInputElement>('input[placeholder="TOOLS.md"]');
expect(input).not.toBeNull();
await act(async () => {
setNativeValue(input!, "notes.md");
buttonByText(container, "Create").click();
});
await waitFor(() => {
expect(container.querySelector('[data-testid="markdown-editor"]')).not.toBeNull();
});
expect(mockAgentsApi.instructionsFile).not.toHaveBeenCalledWith("agent-1", "notes.md", "company-1");
});
it("falls back to extension detection for existing .md files when metadata is missing", async () => {
const summary = makeSummary("FALLBACK.md", "FALLBACK.md", {
language: "text",
markdown: undefined,
});
await renderPromptsTab(
makeBundle("FALLBACK.md", [summary]),
{ "FALLBACK.md": makeDetail(summary, "# Fallback", { markdown: undefined }) },
);
await waitFor(() => {
expect(container.querySelector("[data-testid=\"markdown-editor\"]")).not.toBeNull();
expect(markdownEditorRenderMock).toHaveBeenLastCalledWith(expect.objectContaining({
value: "# Fallback",
}));
});
});
it("keeps the raw textarea when server metadata marks an .md file as non-Markdown", async () => {
const summary = makeSummary("NOTES.md", "NOTES.md", {
language: "text",
markdown: false,
});
await renderPromptsTab(
makeBundle("NOTES.md", [summary]),
{ "NOTES.md": makeDetail(summary, "raw instructions") },
);
await waitFor(() => {
expect(container.querySelector('[data-testid="markdown-editor"]')).toBeNull();
expect(container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]')?.value).toBe("raw instructions");
});
});
});

View File

@ -179,6 +179,17 @@ function isMarkdown(pathValue: string) {
return pathValue.toLowerCase().endsWith(".md");
}
function shouldUseMarkdownInstructionsEditor(input: {
selectedFileExists: boolean;
selectedPath: string;
detail?: { markdown?: boolean } | null;
summary?: { markdown?: boolean } | null;
}) {
const metadataMarkdown = input.detail?.markdown ?? input.summary?.markdown;
if (typeof metadataMarkdown === "boolean") return metadataMarkdown;
return isMarkdown(input.selectedPath);
}
function formatEnvForDisplay(envValue: unknown, censorUsernameInLogs: boolean): string {
const env = asRecord(envValue);
if (!env) return "<unable-to-parse>";
@ -1951,7 +1962,7 @@ function ConfigurationTab({
/* ---- Prompts Tab ---- */
function PromptsTab({
export function PromptsTab({
agent,
companyId,
onDirtyChange,
@ -2190,6 +2201,12 @@ function PromptsTab({
const currentContent = selectedFileExists ? (selectedFileDetail?.content ?? "") : "";
const displayValue = draft ?? currentContent;
const useMarkdownEditor = shouldUseMarkdownInstructionsEditor({
selectedFileExists,
selectedPath: selectedOrEntryFile,
detail: selectedFileDetail,
summary: selectedFileSummary,
});
const bundleDirty = Boolean(
bundleDraft &&
(
@ -2664,14 +2681,14 @@ function PromptsTab({
{selectedFileExists && fileLoading && !selectedFileDetail ? (
<PromptEditorSkeleton />
) : isMarkdown(selectedOrEntryFile) ? (
) : useMarkdownEditor ? (
<MarkdownEditor
key={selectedOrEntryFile}
value={displayValue}
onChange={(value) => setDraft(value ?? "")}
placeholder="# Agent instructions"
className="min-w-0 overflow-hidden"
contentClassName="min-h-(--sz-420px) max-w-full break-words text-sm font-mono"
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 });