mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People add images and files through task message composers. > - A file drop requires a small target inside the composer. > - A screen-wide target makes this action easier. > - Task pages and new-task dialogs use the same composer. > - This pull request adds one shared file-drop target. > - The open dialog receives the drop when it covers a task page. ## Linked Issues or Issue Description Refs #13206. This improves the task composer drop area. Related PRs: #18689 adds image previews. #15964 adds direct uploads in the Artifacts panel. **What existing behavior does this improve?** Image and file drops in the task composer and new-task dialog. **Current behavior** People must drop a file into the composer area. **Proposed behavior** People can drop a file anywhere on the screen when an available composer is present. A full-screen overlay shows the destination. **Reason and benefit** People can add an image without aiming at the text field. **Subsystem affected** React board UI. **Breaking changes** The available composer captures file drops across the screen. Text and link drags retain their current behavior. Upload rules and APIs stay the same. **Additional context** ROADMAP.md lists task discussion and attachments as existing features. This change improves their input area. ## What Changed - Add a shared screen-wide drop target to the task composer. - Give an open task dialog exclusive ownership of file drops. Keep file drops available while a model or assignee picker is open. - Give explicit editor, task attachment, and Artifacts drop areas priority over the screen-wide composer target. Share one marker for these areas. - Keep image and file uploads in description, question, and protocol editors. Read-only editors and editors that delegate uploads to a parent keep the composer fallback. - Prevent browser navigation from file drops while the active composer is read-only. - Use the existing file staging and upload callbacks. - Disable drops during pause, queued message edits, uncertain sends, submission, and read-only states. - Exclude hidden and inert composers. - Clear the overlay after drop, drag exit, cancellation, window blur, and unmount. - Add component tests and two Storybook drag-over examples. ## Verification - 316 tests passed across the drop target, real MarkdownEditor, task composer, new-task dialog, and Artifacts tab. Tests cover image and text-file drops in a real editor, overlay reset, parent and read-only editors, and task attachments. - Two new cases failed before the editor and attachment fixes. Both passed after the fixes. - An earlier expanded run had three new-task upload assertion failures. The five affected cases passed on the unchanged implementation in isolation. The expanded run then passed all 316 tests. The cause of the earlier failures remains unverified. - UI typecheck and UI production build passed. The first typecheck attempt lacked plugin SDK declarations. Building the SDK restored these declarations; typecheck then passed. - Design-token gates, Storybook catalog generation, and whitespace checks passed. - Chromium checks from the design review confirmed one image per screen drop in each view. The editor stayed empty and the overlay cleared. These checks used local Storybook fixtures. - The requested screenshots were accepted before this PR was opened. - Full repository typecheck and build were attempted. Both stopped in the native Runner package because this environment has no cargo executable. - The full repository test run was attempted. It was stopped during the first chat integration shard before a full-suite verdict. The focused UI suite passed. - The first CI attempt did not complete. Typecheck and build logs report runner shutdown signals. Most browser shards were cancelled; their logs could not be retrieved. No complete CI verdict is available. The new commit starts a fresh CI run. - Review findings about Artifacts drops, portaled pickers, read-only drop navigation, and editor drop ownership are fixed. Current-head CI and review are pending. - The previous CI run passed build, typecheck, general tests, and six browser shards. Browser shards 5/8 and 8/8 failed. Their logs returned BlobNotFound; the causes remain unverified. The new commit starts a fresh CI run. Manual check: open a task, drag a PNG over the page header, and drop it. Confirm one image preview. Open a new-task dialog over the task and repeat. Confirm the file appears only in the new task. Cancel the drag and confirm the overlay clears. Open the description editor. Drop an image and then a text file into it. Confirm each upload stays with the description and does not enter the chat draft. ## Risks - Screen-wide file capture changes which surface receives file drops. - Dialog ownership and disabled-state tests limit unintended uploads. - No server, API, database, or migration changes are required. - Full repository acceptance still requires the native Rust toolchain and CI results. ## Model Used OpenAI GPT-6 through Codex. The exact runtime snapshot ID and context-window size were not exposed. Capabilities used: repository inspection, code editing, shell commands, and browser automation. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
import { FILE_DROP_TARGET_ATTRIBUTE } from "@/lib/file-drop-target";
|
|
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
|
|
import { isTextAttachment } from "@/lib/issue-attachments";
|
|
import { useContext, useMemo, useState, type DragEvent, type ReactNode } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { IssueAttachment } from "@paperclipai/shared";
|
|
import { Download, ExternalLink, FileText, Maximize2, Paperclip, Trash2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { FoldCurtain } from "./FoldCurtain";
|
|
import { MarkdownBody } from "./MarkdownBody";
|
|
import { OutputFileTile } from "./issue-output/OutputFileTile";
|
|
import { OutputVideoPlayer } from "./issue-output/OutputVideoPlayer";
|
|
import { formatBytes } from "@/lib/issue-output";
|
|
import {
|
|
attachmentDownloadPath,
|
|
attachmentFilename,
|
|
attachmentOpenPath,
|
|
isImageAttachment,
|
|
isMarkdownAttachment,
|
|
isVideoAttachment,
|
|
} from "@/lib/issue-attachments";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { cn } from "@/lib/utils";
|
|
import { Card } from "@/components/ui/card";
|
|
|
|
interface IssueAttachmentsSectionProps {
|
|
attachments: IssueAttachment[];
|
|
uploadButton?: ReactNode;
|
|
error?: string | null;
|
|
dragActive?: boolean;
|
|
deletePending?: boolean;
|
|
onDelete?: (attachmentId: string) => void;
|
|
onImageClick: (attachment: IssueAttachment) => void;
|
|
onDragEnter?: (evt: DragEvent<HTMLDivElement>) => void;
|
|
onDragOver?: (evt: DragEvent<HTMLDivElement>) => void;
|
|
onDragLeave?: (evt: DragEvent<HTMLDivElement>) => void;
|
|
onDrop?: (evt: DragEvent<HTMLDivElement>) => void;
|
|
}
|
|
|
|
async function fetchAttachmentText(attachment: IssueAttachment) {
|
|
const response = await fetch(attachment.contentPath, {
|
|
headers: { Accept: "text/markdown,text/plain;q=0.9,*/*;q=0.1" },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Unable to load attachment preview (${response.status})`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
function AttachmentActions({
|
|
attachment,
|
|
onDelete,
|
|
deletePending,
|
|
onPreview,
|
|
}: {
|
|
attachment: IssueAttachment;
|
|
onDelete?: (attachmentId: string) => void;
|
|
deletePending?: boolean;
|
|
onPreview?: (attachment: IssueAttachment) => void;
|
|
}) {
|
|
const openText = useContext(TextAttachmentContext);
|
|
const openInPanel = (event: React.MouseEvent) => {
|
|
if (openText && isTextAttachment(attachment) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) { event.preventDefault(); openText(attachment.id, attachmentFilename(attachment)); }
|
|
};
|
|
const filename = attachmentFilename(attachment);
|
|
return (
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{onPreview ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
title="Browse gallery"
|
|
aria-label={`Browse ${filename} in gallery`}
|
|
onClick={() => onPreview(attachment)}
|
|
>
|
|
<Maximize2 className="h-4 w-4" />
|
|
</Button>
|
|
) : null}
|
|
<Button asChild variant="ghost" size="icon-sm" title="Open in new tab">
|
|
<a href={attachmentOpenPath(attachment)} onClick={openInPanel} target="_blank" rel="noreferrer" aria-label={`Open ${filename}`}>
|
|
<ExternalLink className="h-4 w-4" />
|
|
</a>
|
|
</Button>
|
|
<Button asChild variant="ghost" size="icon-sm" title="Download">
|
|
<a href={attachmentDownloadPath(attachment)} aria-label={`Download ${filename}`}>
|
|
<Download className="h-4 w-4" />
|
|
</a>
|
|
</Button>
|
|
{onDelete ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
title="Delete attachment"
|
|
className="text-muted-foreground hover:text-destructive"
|
|
onClick={() => onDelete(attachment.id)}
|
|
disabled={deletePending}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AttachmentMeta({ attachment }: { attachment: IssueAttachment }) {
|
|
return (
|
|
<p className="mt-0.5 text-(length:--text-micro) text-muted-foreground">
|
|
Attachment · {attachment.contentType} · {formatBytes(attachment.byteSize)}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function MarkdownAttachmentCard({
|
|
attachment,
|
|
onDelete,
|
|
deletePending,
|
|
}: {
|
|
attachment: IssueAttachment;
|
|
onDelete?: (attachmentId: string) => void;
|
|
deletePending?: boolean;
|
|
}) {
|
|
const openText = useContext(TextAttachmentContext);
|
|
const openInPanel = (event: React.MouseEvent) => {
|
|
if (openText && isTextAttachment(attachment) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) { event.preventDefault(); openText(attachment.id, attachmentFilename(attachment)); }
|
|
};
|
|
const filename = attachmentFilename(attachment);
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: queryKeys.issues.attachmentPreview(attachment.id),
|
|
queryFn: () => fetchAttachmentText(attachment),
|
|
});
|
|
|
|
return (
|
|
<div id={`attachment-${attachment.id}`} className="scroll-mt-20 rounded-lg border border-border p-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<a href={attachmentOpenPath(attachment)} onClick={openInPanel} className="truncate text-sm font-medium hover:underline" title={filename}>{filename}</a>
|
|
</div>
|
|
<AttachmentMeta attachment={attachment} />
|
|
</div>
|
|
<AttachmentActions attachment={attachment} onDelete={onDelete} deletePending={deletePending} />
|
|
</div>
|
|
<div className="mt-3 rounded-md hover:bg-accent/10">
|
|
{isLoading ? (
|
|
<p className="px-1 py-2 text-xs text-muted-foreground">Loading preview...</p>
|
|
) : error ? (
|
|
<p className="px-1 py-2 text-xs text-destructive">Could not load markdown preview.</p>
|
|
) : (
|
|
<FoldCurtain>
|
|
<MarkdownBody className="paperclip-edit-in-place-content min-h-(--sz-220px) text-sm leading-7" softBreaks={false}>
|
|
{data ?? ""}
|
|
</MarkdownBody>
|
|
</FoldCurtain>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function VideoAttachmentCard({
|
|
attachment,
|
|
onDelete,
|
|
deletePending,
|
|
onPreview,
|
|
}: {
|
|
attachment: IssueAttachment;
|
|
onDelete?: (attachmentId: string) => void;
|
|
deletePending?: boolean;
|
|
onPreview?: (attachment: IssueAttachment) => void;
|
|
}) {
|
|
const filename = attachmentFilename(attachment);
|
|
return (
|
|
<Card id={`attachment-${attachment.id}`} className="block scroll-mt-20 overflow-hidden py-0">
|
|
<OutputVideoPlayer src={attachment.contentPath} title={filename} />
|
|
<div className="flex flex-col gap-2 p-3 md:flex-row md:items-center md:justify-between">
|
|
<div className="min-w-0">
|
|
<p className="break-words text-sm font-semibold text-foreground">{filename}</p>
|
|
<AttachmentMeta attachment={attachment} />
|
|
</div>
|
|
<AttachmentActions
|
|
attachment={attachment}
|
|
onDelete={onDelete}
|
|
deletePending={deletePending}
|
|
onPreview={onPreview}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function GenericAttachmentRow({
|
|
attachment,
|
|
onDelete,
|
|
deletePending,
|
|
}: {
|
|
attachment: IssueAttachment;
|
|
onDelete?: (attachmentId: string) => void;
|
|
deletePending?: boolean;
|
|
}) {
|
|
const openText = useContext(TextAttachmentContext);
|
|
const openInPanel = (event: React.MouseEvent) => {
|
|
if (openText && isTextAttachment(attachment) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) { event.preventDefault(); openText(attachment.id, attachmentFilename(attachment)); }
|
|
};
|
|
const filename = attachmentFilename(attachment);
|
|
return (
|
|
<Card id={`attachment-${attachment.id}`} className="flex-row scroll-mt-20 items-center gap-2.5 p-2">
|
|
<OutputFileTile contentType={attachment.contentType} />
|
|
<div className="min-w-0 flex-1">
|
|
<a
|
|
href={attachmentOpenPath(attachment)} onClick={openInPanel}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="block truncate text-sm font-medium text-foreground hover:underline"
|
|
title={filename}
|
|
>
|
|
{filename}
|
|
</a>
|
|
<p className="truncate text-(length:--text-micro) text-muted-foreground">
|
|
Attachment · {attachment.contentType} · {formatBytes(attachment.byteSize)}
|
|
</p>
|
|
</div>
|
|
<AttachmentActions attachment={attachment} onDelete={onDelete} deletePending={deletePending} />
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function IssueAttachmentsSection({
|
|
attachments,
|
|
uploadButton,
|
|
error,
|
|
dragActive = false,
|
|
deletePending = false,
|
|
onDelete,
|
|
onImageClick,
|
|
onDragEnter,
|
|
onDragOver,
|
|
onDragLeave,
|
|
onDrop,
|
|
}: IssueAttachmentsSectionProps) {
|
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
const { imageAttachments, markdownAttachments, videoAttachments, genericAttachments } = useMemo(() => {
|
|
const images: IssueAttachment[] = [];
|
|
const markdown: IssueAttachment[] = [];
|
|
const videos: IssueAttachment[] = [];
|
|
const generic: IssueAttachment[] = [];
|
|
|
|
for (const attachment of attachments) {
|
|
if (isImageAttachment(attachment)) images.push(attachment);
|
|
else if (isMarkdownAttachment(attachment)) markdown.push(attachment);
|
|
else if (isVideoAttachment(attachment)) videos.push(attachment);
|
|
else generic.push(attachment);
|
|
}
|
|
|
|
return {
|
|
imageAttachments: images,
|
|
markdownAttachments: markdown,
|
|
videoAttachments: videos,
|
|
genericAttachments: generic,
|
|
};
|
|
}, [attachments]);
|
|
|
|
const requestDelete = (attachmentId: string) => setConfirmDeleteId(attachmentId);
|
|
const confirmDelete = (attachmentId: string) => {
|
|
if (!onDelete) return;
|
|
onDelete(attachmentId);
|
|
setConfirmDeleteId(null);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"space-y-3 rounded-lg transition-colors",
|
|
dragActive && "bg-primary/5",
|
|
)}
|
|
{...(onDrop ? { [FILE_DROP_TARGET_ATTRIBUTE]: "" } : {})}
|
|
onDragEnter={onDragEnter}
|
|
onDragOver={onDragOver}
|
|
onDragLeave={onDragLeave}
|
|
onDrop={onDrop}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<Paperclip className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
|
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
|
|
<span className="text-xs text-muted-foreground">{attachments.length}</span>
|
|
</div>
|
|
{uploadButton}
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="text-xs text-destructive">{error}</p>
|
|
)}
|
|
|
|
{imageAttachments.length > 0 && (
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{imageAttachments.map((attachment) => (
|
|
<div
|
|
key={attachment.id}
|
|
id={`attachment-${attachment.id}`}
|
|
className="group relative aspect-square cursor-pointer scroll-mt-20 overflow-hidden rounded-lg border border-border bg-accent/10"
|
|
onClick={() => onImageClick(attachment)}
|
|
>
|
|
<img
|
|
src={attachment.contentPath}
|
|
alt={attachment.originalFilename ?? "attachment"}
|
|
className="h-full w-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/30" />
|
|
{onDelete && confirmDeleteId === attachment.id ? (
|
|
<div
|
|
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<p className="text-xs font-medium text-white">Delete?</p>
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
type="button"
|
|
className="rounded bg-destructive px-2 py-0.5 text-xs text-white hover:bg-destructive/80"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
confirmDelete(attachment.id);
|
|
}}
|
|
disabled={deletePending}
|
|
>
|
|
Yes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded bg-muted px-2 py-0.5 text-xs hover:bg-muted/80"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setConfirmDeleteId(null);
|
|
}}
|
|
>
|
|
No
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : onDelete ? (
|
|
<button
|
|
type="button"
|
|
className="absolute right-1.5 top-1.5 rounded-md bg-black/50 p-1 text-white opacity-0 transition-opacity hover:bg-destructive group-hover:opacity-100"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
requestDelete(attachment.id);
|
|
}}
|
|
title="Delete attachment"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{markdownAttachments.length > 0 && (
|
|
<div className="space-y-3">
|
|
{markdownAttachments.map((attachment) => (
|
|
<MarkdownAttachmentCard
|
|
key={attachment.id}
|
|
attachment={attachment}
|
|
onDelete={onDelete ? requestDelete : undefined}
|
|
deletePending={deletePending}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{videoAttachments.length > 0 && (
|
|
<div className="space-y-3">
|
|
{videoAttachments.map((attachment) => (
|
|
<VideoAttachmentCard
|
|
key={attachment.id}
|
|
attachment={attachment}
|
|
onDelete={onDelete ? requestDelete : undefined}
|
|
deletePending={deletePending}
|
|
onPreview={onImageClick}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{genericAttachments.length > 0 && (
|
|
<div className="space-y-2">
|
|
{genericAttachments.map((attachment) => (
|
|
<GenericAttachmentRow
|
|
key={attachment.id}
|
|
attachment={attachment}
|
|
onDelete={onDelete ? requestDelete : undefined}
|
|
deletePending={deletePending}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{onDelete && confirmDeleteId && !imageAttachments.some((attachment) => attachment.id === confirmDeleteId) ? (
|
|
<div className="flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
|
|
<p className="text-sm font-medium text-destructive">Delete this attachment? This cannot be undone.</p>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => setConfirmDeleteId(null)} disabled={deletePending}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" size="sm" onClick={() => confirmDelete(confirmDeleteId)} disabled={deletePending}>
|
|
{deletePending ? "Deleting..." : "Delete"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|