[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

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) => {

View File

@@ -578,7 +578,7 @@ type IssueDetailChatTabProps = {
) => Promise<void>;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
onImageUpload: (file: File) => Promise<string>;
onAttachImage: (file: File) => Promise<void>;
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
onInterruptQueued: (runId: string) => Promise<void>;
onCancelQueued: (commentId: string) => void;
interruptingQueuedRunId: string | null;
@@ -2543,7 +2543,7 @@ export function IssueDetail() {
return attachment.contentPath;
}, [uploadAttachment]);
const handleCommentAttachImage = useCallback(async (file: File) => {
await uploadAttachment.mutateAsync(file);
return uploadAttachment.mutateAsync(file);
}, [uploadAttachment]);
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
await interruptQueuedComment.mutateAsync(runId);
@@ -3069,6 +3069,7 @@ export function IssueDetail() {
className="text-[15px] leading-7 text-foreground"
placeholder="Add a description..."
multiline
foldable
mentions={mentionOptions}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);

View File

@@ -4,6 +4,7 @@ import type { Agent, CompanySecret, EnvBinding, Project, RoutineVariable } from
import { Code2, FileText, ListPlus, RotateCcw, Table2 } from "lucide-react";
import { EnvVarEditor } from "@/components/EnvVarEditor";
import { ExecutionParticipantPicker } from "@/components/ExecutionParticipantPicker";
import { FoldCurtain } from "@/components/FoldCurtain";
import { InlineEditor } from "@/components/InlineEditor";
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
import { JsonSchemaForm, type JsonSchemaNode, getDefaultValues } from "@/components/JsonSchemaForm";
@@ -710,3 +711,85 @@ export const RoutineRunVariablesDialogOpen: Story = {
name: "Routine Run Variables Dialog",
render: () => <RoutineRunDialogStory />,
};
const foldCurtainLongMarkdown = [
"# paperclip-bench",
"",
"Ship criteria for the benchmark harness — these notes are intentionally lengthy so the fold-curtain clips them.",
"",
"## Overview",
"",
"We need a benchmark that compares agent performance across task types and model backends. This includes:",
"",
"- a **runner** that executes tasks in isolated workspaces",
"- a **scorer** that grades outputs against ground truth",
"- a **dashboard** that trends metrics over time",
"",
"## Task format",
"",
"Each task is a directory containing a `task.md`, an optional `setup.sh`, and an `expected/` fixture. The runner mounts the task, executes the agent, and diffs the resulting workspace against `expected/`.",
"",
"```ts",
"type TaskResult = {",
" taskId: string;",
" agent: string;",
" exitCode: number;",
" scoreBreakdown: Record<string, number>;",
"};",
"```",
"",
"## Metrics",
"",
"| Metric | Description |",
"| --- | --- |",
"| Pass@1 | First-try correctness |",
"| Tokens | Cost per task |",
"| Wall time | End-to-end minutes |",
"",
"## Next steps",
"",
"1. Land the runner with support for 3 task types.",
"2. Backfill 50 tasks from open-source benchmarks.",
"3. Wire the scorer to GitHub Actions.",
"4. Publish baseline numbers on the main branch.",
"",
"All of this is described in more detail in the design doc linked from the home page.",
].join("\n");
const foldCurtainShortMarkdown = "This description is short. No curtain should appear.";
function FoldCurtainStory() {
return (
<StoryShell>
<Section
eyebrow="Presentation"
title="FoldCurtain"
description="Long content collapses to a preview with a bottom fade and a Show more button. Short content renders untouched."
>
<div className="space-y-6">
<StatePanel
label="Long description (collapsed)"
detail="Default state on every fresh page load. Natural height far exceeds the collapsed height, so the curtain activates."
>
<FoldCurtain>
<MarkdownBody className="text-[15px] leading-7">{foldCurtainLongMarkdown}</MarkdownBody>
</FoldCurtain>
</StatePanel>
<StatePanel
label="Short description (no curtain)"
detail="Content below the activation threshold renders with no curtain and no button."
>
<FoldCurtain>
<MarkdownBody className="text-[15px] leading-7">{foldCurtainShortMarkdown}</MarkdownBody>
</FoldCurtain>
</StatePanel>
</div>
</Section>
</StoryShell>
);
}
export const FoldCurtainShowcase: Story = {
name: "Fold Curtain",
render: () => <FoldCurtainStory />,
};