mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[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:
145
ui/src/components/FoldCurtain.tsx
Normal file
145
ui/src/components/FoldCurtain.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = ``;
|
||||
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 = ``;
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("<script>");
|
||||
expect(html).toContain("onclick="steal()"");
|
||||
expect(html).toContain("Plain text");
|
||||
});
|
||||
|
||||
it("uses soft-break styling by default", () => {
|
||||
const html = renderMarkdown("First line\nSecond line");
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ const mdxEditorMockState = vi.hoisted(() => ({
|
||||
suppressHtmlProcessingValues: [] as boolean[],
|
||||
}));
|
||||
|
||||
function containsHtmlLikeTag(markdown: string) {
|
||||
return /<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s[^>]*)?\/?>/.test(markdown);
|
||||
}
|
||||
|
||||
vi.mock("@mdxeditor/editor", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
@@ -63,7 +67,7 @@ vi.mock("@mdxeditor/editor", async () => {
|
||||
}), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!suppressHtmlProcessing && markdown.includes("<img ")) {
|
||||
if (!suppressHtmlProcessing && containsHtmlLikeTag(markdown)) {
|
||||
setContent("");
|
||||
onError?.({
|
||||
error: "Error parsing markdown: HTML-like formatting requires suppressHtmlProcessing",
|
||||
@@ -148,6 +152,17 @@ async function flush() {
|
||||
});
|
||||
}
|
||||
|
||||
function createFileDragEvent(type: string) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
|
||||
dataTransfer: { types: string[]; files: File[]; dropEffect?: string };
|
||||
};
|
||||
event.dataTransfer = {
|
||||
types: ["Files"],
|
||||
files: [],
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("MarkdownEditor", () => {
|
||||
let container: HTMLDivElement;
|
||||
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
|
||||
@@ -251,7 +266,7 @@ describe("MarkdownEditor", () => {
|
||||
await flush();
|
||||
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("");
|
||||
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
|
||||
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(false);
|
||||
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
|
||||
expect(container.textContent).toContain("Before");
|
||||
expect(container.textContent).toContain("After");
|
||||
|
||||
@@ -260,6 +275,55 @@ describe("MarkdownEditor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps arbitrary HTML-like tags in the rich editor instead of falling back to raw source", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value={'<section data-source="paste">\n## My take\n\n<p>Benchmark notes</p>\n</section>'}
|
||||
onChange={() => {}}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
|
||||
expect(container.querySelector("textarea")).toBeNull();
|
||||
expect(container.textContent).toContain("Benchmark notes");
|
||||
expect(container.textContent).not.toContain("Rich editor unavailable for this markdown");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps scriptable pasted HTML inert in the rich editor", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value={'<script>fetch("/api/secrets")</script>\n<iframe src="https://example.com"></iframe>\n<p onclick="steal()">Plain text</p>'}
|
||||
onChange={() => {}}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(true);
|
||||
expect(container.querySelector("textarea")).toBeNull();
|
||||
expect(container.querySelector("script, iframe, p[onclick]")).toBeNull();
|
||||
expect(container.textContent).toContain('fetch("/api/secrets")');
|
||||
expect(container.textContent).toContain("Plain text");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a raw textarea when the rich parser rejects the markdown", async () => {
|
||||
mdxEditorMockState.emitMountParseError = true;
|
||||
const handleChange = vi.fn();
|
||||
@@ -319,6 +383,101 @@ describe("MarkdownEditor", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the editor-scoped dropzone by default when files are dragged over it", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
placeholder="Markdown body"
|
||||
imageUploadHandler={async () => "https://example.com/image.png"}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null;
|
||||
expect(scope).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
scope?.dispatchEvent(createFileDragEvent("dragenter"));
|
||||
});
|
||||
|
||||
expect(scope?.className).toContain("ring-1");
|
||||
expect(container.textContent).toContain("Drop image to upload");
|
||||
|
||||
act(() => {
|
||||
scope?.dispatchEvent(createFileDragEvent("dragleave"));
|
||||
});
|
||||
|
||||
expect(scope?.className).not.toContain("ring-1");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("defers file-drop visuals to a parent container when requested", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
placeholder="Markdown body"
|
||||
imageUploadHandler={async () => "https://example.com/image.png"}
|
||||
fileDropTarget="parent"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null;
|
||||
expect(scope).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
scope?.dispatchEvent(createFileDragEvent("dragenter"));
|
||||
});
|
||||
|
||||
expect(scope?.className).not.toContain("ring-1");
|
||||
expect(container.textContent).not.toContain("Drop image to upload");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show the raw fallback while image-only markdown is settling", async () => {
|
||||
mdxEditorMockState.emitMountSilentEmptyState = true;
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector("textarea")).toBeNull();
|
||||
expect(container.textContent).not.toContain("Rich editor unavailable for this markdown");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
|
||||
expect(
|
||||
computeMentionMenuPosition(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user