forked from farhoodlabs/paperclip
[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:
@@ -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("");
|
||||
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=""
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user