[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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 839 additions and 54 deletions

View file

@ -0,0 +1,145 @@
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FoldCurtainProps {
children: ReactNode;
/** Max height (px) when collapsed. Defaults to 420 (desktop) / 320 (< 640px viewport). */
collapsedHeight?: number;
/** Only curtain when natural height ≥ collapsedHeight + this buffer. */
activationBuffer?: number;
moreLabel?: string;
lessLabel?: string;
className?: string;
contentClassName?: string;
}
const MOBILE_BREAKPOINT = 640;
const MOBILE_COLLAPSED_HEIGHT = 320;
const DEFAULT_COLLAPSED_HEIGHT = 420;
const FADE_HEIGHT_PX = 72;
const EXPAND_TRANSITION_MS = 220;
function useResponsiveCollapsedHeight(explicit?: number) {
const [height, setHeight] = useState<number>(() => {
if (explicit != null) return explicit;
if (typeof window === "undefined") return DEFAULT_COLLAPSED_HEIGHT;
return window.innerWidth < MOBILE_BREAKPOINT
? MOBILE_COLLAPSED_HEIGHT
: DEFAULT_COLLAPSED_HEIGHT;
});
useEffect(() => {
if (explicit != null) {
setHeight(explicit);
return;
}
if (typeof window === "undefined") return;
const compute = () =>
setHeight(
window.innerWidth < MOBILE_BREAKPOINT
? MOBILE_COLLAPSED_HEIGHT
: DEFAULT_COLLAPSED_HEIGHT,
);
compute();
window.addEventListener("resize", compute);
return () => window.removeEventListener("resize", compute);
}, [explicit]);
return height;
}
export function FoldCurtain({
children,
collapsedHeight: explicitCollapsedHeight,
activationBuffer = 120,
moreLabel = "Show more",
lessLabel = "Show less",
className,
contentClassName,
}: FoldCurtainProps) {
const collapsedHeight = useResponsiveCollapsedHeight(explicitCollapsedHeight);
const contentRef = useRef<HTMLDivElement>(null);
const [naturalHeight, setNaturalHeight] = useState(0);
const [expanded, setExpanded] = useState(false);
const [hasMeasured, setHasMeasured] = useState(false);
const [allowTransition, setAllowTransition] = useState(false);
useLayoutEffect(() => {
const el = contentRef.current;
if (!el) return;
const measure = () => {
setNaturalHeight(el.scrollHeight);
setHasMeasured(true);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);
const shouldCurtain = hasMeasured && naturalHeight >= collapsedHeight + activationBuffer;
const isClipped = shouldCurtain && !expanded;
const maskStyle = isClipped
? {
WebkitMaskImage: `linear-gradient(to bottom, black 0, black calc(100% - ${FADE_HEIGHT_PX}px), transparent 100%)`,
maskImage: `linear-gradient(to bottom, black 0, black calc(100% - ${FADE_HEIGHT_PX}px), transparent 100%)`,
}
: undefined;
return (
<div className={cn("fold-curtain", className)} data-expanded={expanded ? "true" : "false"}>
<div
ref={contentRef}
className={cn(
"fold-curtain__content relative overflow-hidden",
allowTransition && "motion-safe:transition-[max-height] motion-reduce:transition-none",
contentClassName,
)}
style={{
maxHeight: isClipped
? `${collapsedHeight}px`
: shouldCurtain
? `${naturalHeight}px`
: undefined,
transitionDuration: allowTransition ? `${EXPAND_TRANSITION_MS}ms` : undefined,
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
...maskStyle,
}}
>
{children}
</div>
{shouldCurtain ? (
<div className="fold-curtain__toggle mt-2 flex justify-center print:hidden">
<Button
type="button"
variant="ghost"
size="sm"
aria-expanded={expanded}
onClick={() => {
setAllowTransition(true);
setExpanded((v) => !v);
}}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
{expanded ? lessLabel : moreLabel}
{expanded ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</Button>
</div>
) : null}
</div>
);
}

View file

@ -3,6 +3,7 @@ import { cn } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
import { FoldCurtain } from "./FoldCurtain";
interface InlineEditorProps {
value: string;
@ -16,6 +17,8 @@ interface InlineEditorProps {
onDropFile?: (file: File) => Promise<void>;
mentions?: MentionOption[];
nullable?: boolean;
/** When true, long display-mode markdown is clipped with a fade curtain that expands on click. */
foldable?: boolean;
}
/** Shared padding so display and edit modes occupy the exact same box. */
@ -51,6 +54,7 @@ export function InlineEditor({
imageUploadHandler,
onDropFile,
mentions,
foldable = false,
}: InlineEditorProps) {
const [editing, setEditing] = useState(false);
const [multilineEditing, setMultilineEditing] = useState(false);
@ -282,9 +286,17 @@ export function InlineEditor({
aria-label={placeholder}
tabIndex={0}
>
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
{previewValue}
</MarkdownBody>
{foldable ? (
<FoldCurtain>
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
{previewValue}
</MarkdownBody>
</FoldCurtain>
) : (
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
{previewValue}
</MarkdownBody>
)}
</div>
);
}

View file

@ -69,12 +69,14 @@ vi.mock("./MarkdownEditor", () => ({
placeholder,
className,
contentClassName,
fileDropTarget,
}: {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
contentClassName?: string;
fileDropTarget?: "editor" | "parent";
}, ref) => {
useImperativeHandle(ref, () => ({
focus: markdownEditorFocusMock,
@ -85,6 +87,7 @@ vi.mock("./MarkdownEditor", () => ({
aria-label="Issue chat editor"
data-class-name={className}
data-content-class-name={contentClassName}
data-file-drop-target={fileDropTarget}
placeholder={placeholder}
value={value}
onChange={(event) => onChange?.(event.target.value)}
@ -249,6 +252,21 @@ function createExpiredRequestConfirmationInteraction(
};
}
function createFileDragEvent(type: string, files: File[]) {
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("IssueChatThread", () => {
let container: HTMLDivElement;
@ -818,12 +836,210 @@ describe("IssueChatThread", () => {
expect(editor?.dataset.contentClassName).toContain("max-h-[28dvh]");
expect(editor?.dataset.contentClassName).toContain("overflow-y-auto");
expect(editor?.dataset.contentClassName).not.toContain("min-h-[72px]");
expect(editor?.dataset.fileDropTarget).toBe("parent");
act(() => {
root.unmount();
});
});
it("shows full-composer drop instructions while dragging files over the issue composer", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
imageUploadHandler={async () => "/api/attachments/image/content"}
onAttachImage={async () => undefined}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
expect(composer).not.toBeNull();
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(fileInput?.getAttribute("accept")).toBeNull();
act(() => {
composer?.dispatchEvent(createFileDragEvent("dragenter", [
new File(["hello"], "notes.txt", { type: "text/plain" }),
]));
});
expect(container.querySelector('[data-testid="issue-chat-composer-drop-overlay"]')).not.toBeNull();
expect(container.textContent).toContain("Drop to upload");
expect(container.textContent).toContain("Images insert into the reply");
expect(container.textContent).toContain("Other files are added to this issue");
expect(composer?.className).toContain("border-primary/45");
act(() => {
root.unmount();
});
});
it("shows non-image attachment upload state in the composer after a drop", async () => {
const root = createRoot(container);
const onAttachImage = vi.fn(async (file: File) => ({
id: "attachment-1",
companyId: "company-1",
issueId: "issue-1",
issueCommentId: null,
assetId: "asset-1",
provider: "local_disk",
objectKey: "issues/issue-1/report.pdf",
contentPath: "/api/attachments/attachment-1/content",
originalFilename: file.name,
contentType: file.type,
byteSize: file.size,
sha256: "abc123",
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date("2026-04-24T12:00:00.000Z"),
updatedAt: new Date("2026-04-24T12:00:00.000Z"),
}));
await act(async () => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
onAttachImage={onAttachImage}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
const file = new File(["report body"], "report.pdf", { type: "application/pdf" });
await act(async () => {
composer?.dispatchEvent(createFileDragEvent("drop", [file]));
});
expect(onAttachImage).toHaveBeenCalledWith(file);
const attachmentList = container.querySelector('[data-testid="issue-chat-composer-attachments"]');
expect(attachmentList).not.toBeNull();
expect(container.textContent).toContain("report.pdf");
expect(container.textContent).toContain("Attached to issue");
await act(async () => {
root.unmount();
});
});
it("shows only the outer composer drop overlay when dragging over the reply editor", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
imageUploadHandler={async () => "/api/attachments/image/content"}
onAttachImage={async () => undefined}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
expect(composer).not.toBeNull();
expect(editor).not.toBeNull();
act(() => {
editor?.dispatchEvent(createFileDragEvent("dragenter", [
new File(["hello"], "notes.txt", { type: "text/plain" }),
]));
});
expect(container.querySelector('[data-testid="issue-chat-composer-drop-overlay"]')).not.toBeNull();
expect(container.textContent).toContain("Drop to upload");
expect(container.textContent).not.toContain("Drop image to upload");
expect(composer?.className).toContain("border-primary/45");
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(fileInput?.getAttribute("accept")).toBeNull();
act(() => {
root.unmount();
});
});
it("shows non-image attachment upload state in the composer after a drop from the editor", async () => {
const root = createRoot(container);
const onAttachImage = vi.fn(async (file: File) => ({
id: "attachment-1",
companyId: "company-1",
issueId: "issue-1",
issueCommentId: null,
assetId: "asset-1",
provider: "local_disk",
objectKey: "issues/issue-1/report.pdf",
contentPath: "/api/attachments/attachment-1/content",
originalFilename: file.name,
contentType: file.type,
byteSize: file.size,
sha256: "abc123",
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date("2026-04-24T12:00:00.000Z"),
updatedAt: new Date("2026-04-24T12:00:00.000Z"),
}));
await act(async () => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
onAttachImage={onAttachImage}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
const file = new File(["report body"], "report.pdf", { type: "application/pdf" });
await act(async () => {
editor?.dispatchEvent(createFileDragEvent("drop", [file]));
});
expect(onAttachImage).toHaveBeenCalledWith(file);
const attachmentList = container.querySelector('[data-testid="issue-chat-composer-attachments"]');
expect(attachmentList).not.toBeNull();
expect(attachmentList?.className).toContain("mb-3");
expect(container.textContent).toContain("report.pdf");
expect(container.textContent).toContain("Attached to issue");
await act(async () => {
root.unmount();
});
});
it("renders the bottom spacer with zero height until the user has submitted", () => {
const root = createRoot(container);

View file

@ -31,6 +31,7 @@ import type {
FeedbackDataSharingPreference,
FeedbackVote,
FeedbackVoteValue,
IssueAttachment,
IssueRelationIssueSummary,
} from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
@ -219,7 +220,7 @@ export interface IssueChatComposerHandle {
interface IssueChatComposerProps {
onImageUpload?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<void>;
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
draftKey?: string;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
@ -259,7 +260,7 @@ interface IssueChatThreadProps {
onCancelRun?: () => Promise<void>;
onStopRun?: (runId: string) => Promise<void>;
imageUploadHandler?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<void>;
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
draftKey?: string;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
@ -508,10 +509,27 @@ const DRAFT_DEBOUNCE_MS = 800;
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
const SUBMIT_SCROLL_RESERVE_VH = 0.4;
type ComposerAttachmentItem = {
id: string;
name: string;
size: number;
status: "uploading" | "attached" | "error";
inline: boolean;
contentPath?: string;
error?: string;
};
function hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
}
function formatAttachmentSize(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function toIsoString(value: string | Date | null | undefined): string | null {
if (!value) return null;
return typeof value === "string" ? value : value.toISOString();
@ -2055,6 +2073,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [composerAttachments, setComposerAttachments] = useState<ComposerAttachmentItem[]>([]);
const dragDepthRef = useRef(0);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
@ -2148,6 +2167,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
queueViewportRestore(viewportSnapshot);
await appendPromise;
if (draftKey) clearDraft(draftKey);
setComposerAttachments([]);
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
setBody((current) =>
@ -2163,13 +2183,59 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
}
async function attachFile(file: File) {
if (onImageUpload && file.type.startsWith("image/")) {
const url = await onImageUpload(file);
const safeName = file.name.replace(/[[\]]/g, "\\$&");
const markdown = `![${safeName}](${url})`;
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
} else if (onAttachImage) {
await onAttachImage(file);
const attachmentId = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
const inline = Boolean(onImageUpload && file.type.startsWith("image/"));
setComposerAttachments((prev) => [
...prev,
{
id: attachmentId,
name: file.name,
size: file.size,
status: "uploading",
inline,
},
]);
try {
if (onImageUpload && file.type.startsWith("image/")) {
const url = await onImageUpload(file);
const safeName = file.name.replace(/[[\]]/g, "\\$&");
const markdown = `![${safeName}](${url})`;
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
setComposerAttachments((prev) => prev.map((item) =>
item.id === attachmentId
? { ...item, status: "attached", contentPath: url }
: item,
));
} else if (onAttachImage) {
const attachment = await onAttachImage(file);
setComposerAttachments((prev) => prev.map((item) =>
item.id === attachmentId
? {
...item,
status: "attached",
contentPath: attachment?.contentPath,
name: attachment?.originalFilename ?? item.name,
}
: item,
));
} else {
setComposerAttachments((prev) => prev.map((item) =>
item.id === attachmentId
? { ...item, status: "error", error: "This file type cannot be attached here" }
: item,
));
}
} catch (err) {
setComposerAttachments((prev) => prev.map((item) =>
item.id === attachmentId
? {
...item,
status: "error",
error: err instanceof Error ? err.message : "Upload failed",
}
: item,
));
}
}
@ -2202,6 +2268,37 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
setIsDragOver(false);
}
function handleFileDragEnter(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
evt.stopPropagation();
dragDepthRef.current += 1;
setIsDragOver(true);
}
function handleFileDragOver(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
evt.stopPropagation();
evt.dataTransfer.dropEffect = "copy";
}
function handleFileDragLeave(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
evt.stopPropagation();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false);
}
function handleFileDrop(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
evt.stopPropagation();
resetDragState();
void handleDroppedFiles(evt.dataTransfer?.files);
}
const canSubmit = !submitting && !!body.trim();
if (composerDisabledReason) {
@ -2217,35 +2314,33 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
ref={composerContainerRef}
data-testid="issue-chat-composer"
className={cn(
"relative rounded-md border border-border/70 bg-background/95 p-[15px] shadow-[0_-12px_28px_rgba(15,23,42,0.08)] backdrop-blur supports-[backdrop-filter]:bg-background/85 dark:shadow-[0_-12px_28px_rgba(0,0,0,0.28)]",
isDragOver && "ring-2 ring-primary/60 bg-accent/10",
"relative rounded-md border border-border/70 bg-background/95 p-[15px] shadow-[0_-12px_28px_rgba(15,23,42,0.08)] backdrop-blur transition-[border-color,background-color,box-shadow] duration-150 supports-[backdrop-filter]:bg-background/85 dark:shadow-[0_-12px_28px_rgba(0,0,0,0.28)]",
isDragOver && "border-primary/45 bg-background shadow-[0_-12px_28px_rgba(15,23,42,0.08),0_0_0_1px_hsl(var(--primary)/0.16)]",
)}
onDragEnter={(evt) => {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
dragDepthRef.current += 1;
setIsDragOver(true);
}}
onDragOver={(evt) => {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
evt.dataTransfer.dropEffect = "copy";
}}
onDragLeave={() => {
if (!canAcceptFiles) return;
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false);
}}
onDrop={(evt) => {
if (!canAcceptFiles) return;
if (evt.defaultPrevented) {
resetDragState();
return;
}
evt.preventDefault();
resetDragState();
void handleDroppedFiles(evt.dataTransfer?.files);
}}
onDragEnterCapture={handleFileDragEnter}
onDragOverCapture={handleFileDragOver}
onDragLeaveCapture={handleFileDragLeave}
onDropCapture={handleFileDrop}
>
{isDragOver && canAcceptFiles ? (
<div
data-testid="issue-chat-composer-drop-overlay"
className="pointer-events-none absolute inset-2 z-30 flex items-center justify-center rounded-sm border border-dashed border-primary/55 bg-background/75 px-4 py-3 text-center shadow-sm backdrop-blur-[2px] dark:bg-background/65"
>
<div className="flex max-w-md items-center gap-3 rounded-md bg-background/80 px-3 py-2 text-left shadow-sm ring-1 ring-border/60">
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Paperclip className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Drop to upload</div>
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">
Images insert into the reply. Other files are added to this issue.
</div>
</div>
</div>
</div>
) : null}
<MarkdownEditor
ref={editorRef}
value={body}
@ -2254,8 +2349,9 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={onImageUpload}
fileDropTarget="parent"
bordered={false}
contentClassName="max-h-[28dvh] overflow-y-auto pr-1 text-sm scrollbar-auto-hide"
contentClassName="max-h-[28dvh] overflow-y-auto pr-1 pb-2 text-sm scrollbar-auto-hide"
/>
{composerHint ? (
@ -2264,13 +2360,57 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
</div>
) : null}
{composerAttachments.length > 0 ? (
<div
data-testid="issue-chat-composer-attachments"
className="mb-3 mt-2 space-y-1.5 rounded-md border border-dashed border-border/80 bg-muted/20 p-2"
>
{composerAttachments.map((attachment) => {
const sizeLabel = formatAttachmentSize(attachment.size);
const statusLabel =
attachment.status === "uploading"
? "Uploading to issue"
: attachment.status === "error"
? attachment.error ?? "Upload failed"
: attachment.inline
? "Inserted inline"
: "Attached to issue";
return (
<div
key={attachment.id}
className={cn(
"flex min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-xs",
attachment.status === "error"
? "bg-destructive/10 text-destructive"
: "bg-background/70 text-muted-foreground",
)}
>
{attachment.status === "uploading" ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : attachment.status === "attached" ? (
<Check className="h-3.5 w-3.5 shrink-0 text-green-600 dark:text-green-400" />
) : (
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
{attachment.name}
</span>
{sizeLabel ? (
<span className="shrink-0 text-muted-foreground">{sizeLabel}</span>
) : null}
<span className="shrink-0 text-muted-foreground">{statusLabel}</span>
</div>
);
})}
</div>
) : null}
<div className="flex flex-wrap items-center justify-end gap-3">
{(onImageUpload || onAttachImage) ? (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachFile}
/>
@ -2279,7 +2419,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
size="icon-sm"
onClick={() => attachInputRef.current?.click()}
disabled={attaching}
title="Attach image"
title="Attach file"
>
<Paperclip className="h-4 w-4" />
</Button>

View file

@ -16,6 +16,7 @@ import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
import { deriveDocumentRevisionState } from "../lib/document-revisions";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { FoldCurtain } from "./FoldCurtain";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
@ -70,8 +71,12 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
}
function renderBody(body: string, className?: string) {
return <MarkdownBody className={className} softBreaks={false}>{body}</MarkdownBody>;
function renderFoldableBody(body: string, className?: string) {
return (
<FoldCurtain>
<MarkdownBody className={className} softBreaks={false}>{body}</MarkdownBody>
</FoldCurtain>
);
}
function isPlanKey(key: string) {
@ -780,7 +785,7 @@ export function IssueDocumentsSection({
</span>
</div>
<div className={documentBodyPaddingClassName}>
{renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
</div>
</div>
) : null}
@ -1067,7 +1072,7 @@ export function IssueDocumentsSection({
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
) : null}
{renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
{renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
</div>
)}
</div>
@ -1089,7 +1094,7 @@ export function IssueDocumentsSection({
>
{isHistoricalPreview ? (
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
{renderBody(displayedBody, documentBodyContentClassName)}
{renderFoldableBody(displayedBody, documentBodyContentClassName)}
</div>
) : activeDraft ? (
<MarkdownEditor
@ -1113,7 +1118,7 @@ export function IssueDocumentsSection({
/>
) : (
<div className="rounded-md border border-border/60 bg-background/40 p-3">
{renderBody(displayedBody, documentBodyContentClassName)}
{renderFoldableBody(displayedBody, documentBodyContentClassName)}
</div>
)}
</div>

View file

@ -119,6 +119,20 @@ describe("MarkdownBody", () => {
expect(html).not.toContain("javascript:");
});
it("renders raw HTML tags as escaped text", () => {
const html = renderMarkdown(
'<script>fetch("/api/secrets")</script>\n<iframe src="https://example.com"></iframe>\n<p onclick="steal()">Plain text</p>',
);
expect(html).not.toContain("<script>");
expect(html).not.toContain("<iframe");
expect(html).not.toContain("<p onclick");
expect(html).not.toContain('onclick="steal()"');
expect(html).toContain("&lt;script&gt;");
expect(html).toContain("onclick=&quot;steal()&quot;");
expect(html).toContain("Plain text");
});
it("uses soft-break styling by default", () => {
const html = renderMarkdown("First line\nSecond line");

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(

View file

@ -68,6 +68,8 @@ interface MarkdownEditorProps {
imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor (e.g. .zip). */
onDropFile?: (file: File) => Promise<void>;
/** When set to `parent`, a wrapper owns drag/drop behavior and visuals. */
fileDropTarget?: "editor" | "parent";
bordered?: boolean;
/** List of mentionable entities. Enables @-mention autocomplete. */
mentions?: MentionOption[];
@ -126,6 +128,10 @@ function hasMeaningfulEditorContent(node: Node | null): boolean {
return Array.from(element.childNodes).some((child) => hasMeaningfulEditorContent(child));
}
function hasMarkdownImage(value: string): boolean {
return /!\[[\s\S]*?\]\([^)]+\)/.test(value);
}
function isRichEditorDomEmpty(
editable: HTMLElement,
expectedValue: string,
@ -133,9 +139,11 @@ function isRichEditorDomEmpty(
): boolean {
const expectedText = expectedValue.trim();
if (!expectedText) return false;
const expectedHasImage = hasMarkdownImage(expectedText);
const visibleText = (editable.textContent ?? "").trim();
if (visibleText.length === 0) {
if (expectedHasImage) return false;
return !Array.from(editable.childNodes).some((child) => hasMeaningfulEditorContent(child));
}
@ -145,6 +153,7 @@ function isRichEditorDomEmpty(
&& visibleText === normalizedPlaceholder
&& expectedText !== normalizedPlaceholder
) {
if (expectedHasImage) return false;
return true;
}
@ -491,6 +500,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
onBlur,
imageUploadHandler,
onDropFile,
fileDropTarget = "editor",
bordered = true,
mentions,
onSubmit,
@ -897,8 +907,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
}
const canDropImage = Boolean(imageUploadHandler);
const canDropFile = Boolean(imageUploadHandler || onDropFile);
const canDropFile = fileDropTarget === "editor" && Boolean(imageUploadHandler || onDropFile);
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
const clipboard = event.clipboardData;
if (!clipboard || !ref.current) return;
@ -1082,6 +1091,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
<MDXEditor
ref={setEditorRef}
markdown={editorValue}
suppressHtmlProcessing
placeholder={placeholder}
readOnly={readOnly}
onChange={(next) => {