diff --git a/ui/src/components/FoldCurtain.tsx b/ui/src/components/FoldCurtain.tsx new file mode 100644 index 00000000..a6370b26 --- /dev/null +++ b/ui/src/components/FoldCurtain.tsx @@ -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(() => { + 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(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 ( +
+
+ {children} +
+ {shouldCurtain ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index f866eecd..192e8260 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -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; 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} > - - {previewValue} - + {foldable ? ( + + + {previewValue} + + + ) : ( + + {previewValue} + + )} ); } diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 920eba15..ae114005 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -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( + + {}} + imageUploadHandler={async () => "/api/attachments/image/content"} + onAttachImage={async () => undefined} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + 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( + + {}} + onAttachImage={onAttachImage} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + 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( + + {}} + imageUploadHandler={async () => "/api/attachments/image/content"} + onAttachImage={async () => undefined} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + 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( + + {}} + onAttachImage={onAttachImage} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + 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); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index ea1d4d3a..01fe8f56 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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; - onAttachImage?: (file: File) => Promise; + onAttachImage?: (file: File) => Promise; draftKey?: string; enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; @@ -259,7 +260,7 @@ interface IssueChatThreadProps { onCancelRun?: () => Promise; onStopRun?: (runId: string) => Promise; imageUploadHandler?: (file: File) => Promise; - onAttachImage?: (file: File) => Promise; + onAttachImage?: (file: File) => Promise; 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) { 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([]); const dragDepthRef = useRef(0); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); @@ -2148,6 +2167,7 @@ const IssueChatComposer = forwardRef @@ -2163,13 +2183,59 @@ const IssueChatComposer = forwardRef 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) { + if (!canAcceptFiles || !hasFilePayload(evt)) return; + evt.preventDefault(); + evt.stopPropagation(); + dragDepthRef.current += 1; + setIsDragOver(true); + } + + function handleFileDragOver(evt: ReactDragEvent) { + if (!canAcceptFiles || !hasFilePayload(evt)) return; + evt.preventDefault(); + evt.stopPropagation(); + evt.dataTransfer.dropEffect = "copy"; + } + + function handleFileDragLeave(evt: ReactDragEvent) { + 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) { + 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 { - 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 ? ( +
+
+ + + +
+
Drop to upload
+
+ Images insert into the reply. Other files are added to this issue. +
+
+
+
+ ) : null} + {composerHint ? ( @@ -2264,13 +2360,57 @@ const IssueChatComposer = forwardRef ) : null} + {composerAttachments.length > 0 ? ( +
+ {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 ( +
+ {attachment.status === "uploading" ? ( + + ) : attachment.status === "attached" ? ( + + ) : ( + + )} + + {attachment.name} + + {sizeLabel ? ( + {sizeLabel} + ) : null} + {statusLabel} +
+ ); + })} +
+ ) : null} +
{(onImageUpload || onAttachImage) ? (
@@ -2279,7 +2419,7 @@ const IssueChatComposer = forwardRef attachInputRef.current?.click()} disabled={attaching} - title="Attach image" + title="Attach file" > diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 0bb66002..74a40656 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -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 {body}; +function renderFoldableBody(body: string, className?: string) { + return ( + + {body} + + ); } function isPlanKey(key: string) { @@ -780,7 +785,7 @@ export function IssueDocumentsSection({
- {renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)} + {renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
) : null} @@ -1067,7 +1072,7 @@ export function IssueDocumentsSection({ {!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (

{activeConflict.serverDocument.title}

) : null} - {renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")} + {renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7")} )} @@ -1089,7 +1094,7 @@ export function IssueDocumentsSection({ > {isHistoricalPreview ? (
- {renderBody(displayedBody, documentBodyContentClassName)} + {renderFoldableBody(displayedBody, documentBodyContentClassName)}
) : activeDraft ? ( ) : (
- {renderBody(displayedBody, documentBodyContentClassName)} + {renderFoldableBody(displayedBody, documentBodyContentClassName)}
)} diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 894fa8a7..63e92cb5 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -119,6 +119,20 @@ describe("MarkdownBody", () => { expect(html).not.toContain("javascript:"); }); + it("renders raw HTML tags as escaped text", () => { + const html = renderMarkdown( + '\n\n

Plain text

', + ); + + expect(html).not.toContain("\n\n

Plain text

'} + 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( + {}} + 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( + {}} + 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( + {}} + 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( diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 64dfba35..0ca8e0b6 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -68,6 +68,8 @@ interface MarkdownEditorProps { imageUploadHandler?: (file: File) => Promise; /** Called when a non-image file is dropped onto the editor (e.g. .zip). */ onDropFile?: (file: File) => Promise; + /** 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 onBlur, imageUploadHandler, onDropFile, + fileDropTarget = "editor", bordered = true, mentions, onSubmit, @@ -897,8 +907,7 @@ export const MarkdownEditor = forwardRef 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) => { const clipboard = event.clipboardData; if (!clipboard || !ref.current) return; @@ -1082,6 +1091,7 @@ export const MarkdownEditor = forwardRef { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a4345e0a..89e2ef16 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -578,7 +578,7 @@ type IssueDetailChatTabProps = { ) => Promise; onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; onImageUpload: (file: File) => Promise; - onAttachImage: (file: File) => Promise; + onAttachImage: (file: File) => Promise; onInterruptQueued: (runId: string) => Promise; 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); diff --git a/ui/storybook/stories/forms-editors.stories.tsx b/ui/storybook/stories/forms-editors.stories.tsx index b73a2769..c8e49573 100644 --- a/ui/storybook/stories/forms-editors.stories.tsx +++ b/ui/storybook/stories/forms-editors.stories.tsx @@ -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: () => , }; + +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;", + "};", + "```", + "", + "## 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 ( + +
+
+ + + {foldCurtainLongMarkdown} + + + + + {foldCurtainShortMarkdown} + + +
+
+
+ ); +} + +export const FoldCurtainShowcase: Story = { + name: "Fold Curtain", + render: () => , +};