[codex] Polish issue composer and long document display (#4420)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Issue comments and documents are the main working surface where
operators and agents collaborate
> - File drops, markdown editing, and long issue descriptions need to
feel predictable because they sit directly in the task execution loop
> - The composer had edge cases around drag targets, attachment
feedback, image drops, and long markdown content crowding the page
> - This pull request polishes the issue composer, hardens markdown
editor regressions, and adds a fold curtain for long issue
descriptions/documents
> - The benefit is a calmer issue detail surface that handles uploads
and long work products without hiding state or breaking layout

## What Changed

- Scoped issue-composer drag/drop behavior so the composer owns file
drops without turning the whole thread into a competing drop target.
- Added clearer attachment upload feedback for non-image files and
image-drop stability coverage.
- Hardened markdown editor and markdown body handling around HTML-like
tag regressions.
- Added `FoldCurtain` and wired it into issue descriptions and issue
documents so long markdown previews can expand/collapse.
- Added Storybook coverage for the fold curtain state.

## Verification

- `pnpm exec vitest run ui/src/components/IssueChatThread.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/MarkdownBody.test.tsx --config ui/vitest.config.ts`
passed: 3 files, 75 tests.
- `git diff --check public-gh/master..pap-2228-editor-composer-polish --
. ':(exclude)ui/storybook-static'` passed.
- Confirmed this PR does not include `pnpm-lock.yaml`.

## Risks

- Low-to-medium risk: this changes user-facing composer/drop behavior
and long markdown display.
- The fold curtain uses DOM measurement and `ResizeObserver`; reviewers
should check browser behavior for very long descriptions and documents.
- No database migrations.

> 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 Codex coding agent based on GPT-5, with shell, git, Paperclip
API, and GitHub CLI tool use in the local Paperclip workspace.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Note: screenshots were not newly captured during branch splitting; the
UI states are covered by component tests and a Storybook story.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-04-24 14:12:41 -05:00
committed by GitHub
parent 8f1cd0474f
commit 77a72e28c2
10 changed files with 839 additions and 54 deletions
+161 -2
View File
@@ -22,6 +22,10 @@ const mdxEditorMockState = vi.hoisted(() => ({
suppressHtmlProcessingValues: [] as boolean[],
}));
function containsHtmlLikeTag(markdown: string) {
return /<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s[^>]*)?\/?>/.test(markdown);
}
vi.mock("@mdxeditor/editor", async () => {
const React = await import("react");
@@ -63,7 +67,7 @@ vi.mock("@mdxeditor/editor", async () => {
}), []);
React.useEffect(() => {
if (!suppressHtmlProcessing && markdown.includes("<img ")) {
if (!suppressHtmlProcessing && containsHtmlLikeTag(markdown)) {
setContent("");
onError?.({
error: "Error parsing markdown: HTML-like formatting requires suppressHtmlProcessing",
@@ -148,6 +152,17 @@ async function flush() {
});
}
function createFileDragEvent(type: string) {
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
dataTransfer: { types: string[]; files: File[]; dropEffect?: string };
};
event.dataTransfer = {
types: ["Files"],
files: [],
};
return event;
}
describe("MarkdownEditor", () => {
let container: HTMLDivElement;
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
@@ -251,7 +266,7 @@ describe("MarkdownEditor", () => {
await flush();
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("![image](https://example.com/test.png)");
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(false);
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
expect(container.textContent).toContain("Before");
expect(container.textContent).toContain("After");
@@ -260,6 +275,55 @@ describe("MarkdownEditor", () => {
});
});
it("keeps arbitrary HTML-like tags in the rich editor instead of falling back to raw source", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value={'<section data-source="paste">\n## My take\n\n<p>Benchmark notes</p>\n</section>'}
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
expect(container.querySelector("textarea")).toBeNull();
expect(container.textContent).toContain("Benchmark notes");
expect(container.textContent).not.toContain("Rich editor unavailable for this markdown");
await act(async () => {
root.unmount();
});
});
it("keeps scriptable pasted HTML inert in the rich editor", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value={'<script>fetch("/api/secrets")</script>\n<iframe src="https://example.com"></iframe>\n<p onclick="steal()">Plain text</p>'}
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
expect(container.querySelector("textarea")).toBeNull();
expect(container.querySelector("script, iframe, p[onclick]")).toBeNull();
expect(container.textContent).toContain('fetch("/api/secrets")');
expect(container.textContent).toContain("Plain text");
await act(async () => {
root.unmount();
});
});
it("falls back to a raw textarea when the rich parser rejects the markdown", async () => {
mdxEditorMockState.emitMountParseError = true;
const handleChange = vi.fn();
@@ -319,6 +383,101 @@ describe("MarkdownEditor", () => {
root.unmount();
});
});
it("shows the editor-scoped dropzone by default when files are dragged over it", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value=""
onChange={() => {}}
placeholder="Markdown body"
imageUploadHandler={async () => "https://example.com/image.png"}
/>,
);
});
await flush();
const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null;
expect(scope).not.toBeNull();
act(() => {
scope?.dispatchEvent(createFileDragEvent("dragenter"));
});
expect(scope?.className).toContain("ring-1");
expect(container.textContent).toContain("Drop image to upload");
act(() => {
scope?.dispatchEvent(createFileDragEvent("dragleave"));
});
expect(scope?.className).not.toContain("ring-1");
await act(async () => {
root.unmount();
});
});
it("defers file-drop visuals to a parent container when requested", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value=""
onChange={() => {}}
placeholder="Markdown body"
imageUploadHandler={async () => "https://example.com/image.png"}
fileDropTarget="parent"
/>,
);
});
await flush();
const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null;
expect(scope).not.toBeNull();
act(() => {
scope?.dispatchEvent(createFileDragEvent("dragenter"));
});
expect(scope?.className).not.toContain("ring-1");
expect(container.textContent).not.toContain("Drop image to upload");
await act(async () => {
root.unmount();
});
});
it("does not show the raw fallback while image-only markdown is settling", async () => {
mdxEditorMockState.emitMountSilentEmptyState = true;
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="![Screenshot](/api/attachments/image/content)"
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
await flush();
expect(container.querySelector("textarea")).toBeNull();
expect(container.textContent).not.toContain("Rich editor unavailable for this markdown");
await act(async () => {
root.unmount();
});
});
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
expect(
computeMentionMenuPosition(