Keep issue composer as sole drop target

This commit is contained in:
Dotta committed 2026-04-24 07:39:02 -05:00
1 parent 82f7a15c9f
commit 38487c6d36
3 files changed
+292 -40

No files matched your search

+113
View File
@@ -249,6 +249,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;
@@ -824,6 +839,104 @@ describe("IssueChatThread", () => {
});
});
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);
expect(container.querySelector('[data-testid="issue-chat-composer-attachments"]')).not.toBeNull();
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);
+177 -38
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}
@@ -2264,13 +2359,57 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
</div>
) : null}
{composerAttachments.length > 0 ? (
<div
data-testid="issue-chat-composer-attachments"
className="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 +2418,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>
+2 -2
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);