FN-7304: add Quick Add drag-and-drop attachments

Quick Add now supports compact icon-only image attachment controls and drag/drop intake.

- Convert the Quick Add attach action to an accessible icon-only button with pending-count feedback.
- Add file drag-over and drop handling that reuses the existing image preview/upload path.
- Document the Quick Add attachment affordances, add a changeset, and cover icon/drop behavior with tests.

Files changed:
 .changeset/fn-7304-quick-add-attachments.md        |   7 ++
 docs/dashboard-guide.md                            |   3 +
 .../dashboard/app/components/QuickEntryBox.css     |  49 ++++++++
 .../dashboard/app/components/QuickEntryBox.tsx     |  81 ++++++++++++-
 .../components/__tests__/QuickEntryBox.test.tsx    | 128 ++++++++++++++++++++-
 5 files changed, 257 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-7304

Fusion-Task-Lineage: 0ff78d99-5b6a-476c-99d2-32f53389f808

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 14:42:45 -07:00
parent da15c1ceee
commit 21fc28698f
5 changed files with 257 additions and 11 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Add icon-only Quick Add image attachments with drag-and-drop support.
category: feature
dev: QuickEntryBox now shares image selection, paste, and drop intake with accessible pending-count labels.

View File

@@ -355,6 +355,9 @@ The **New Task** dialog's workflow selector also defaults to the current or last
Optional workflow steps declared by the active workflow are available from the quick-add action row and the **New Task** dialog's inline quick buttons. For example, the coding workflow's browser verification option appears as a quick drop-down when that workflow is active; each option is seeded from the workflow step's `defaultOn` setting and is sent with the task's `enabledWorkflowSteps` payload at creation time. Optional workflow steps declared by the active workflow are available from the quick-add action row and the **New Task** dialog's inline quick buttons. For example, the coding workflow's browser verification option appears as a quick drop-down when that workflow is active; each option is seeded from the workflow step's `defaultOn` setting and is sent with the task's `enabledWorkflowSteps` payload at creation time.
<!-- FNXC:QuickAddAttachments 2026-06-30-00:00: Quick Add attachments use a compact icon-only paperclip while keeping the Attach action and pending image count in accessible labels. The same pending preview/upload path accepts image selection, paste, and direct drag/drop onto the Quick Add box. -->
Quick Add image attachments use the paperclip icon button in the action row. Supported image files (`png`, `jpeg`, `gif`, `webp`) can be selected from that control, pasted into the Quick Add input, or dragged onto the Quick Add box; all three paths show pending previews before task creation and upload the images to the created task afterward.
Quick entry, inline quick-create, and the full **New Task** dialog all check for similar active tasks before creating. When possible duplicates exist, the warning lists each match by task description (falling back to title, then “No description”) and lets you open an existing task, cancel, or create anyway with the duplicates acknowledged. Quick entry, inline quick-create, and the full **New Task** dialog all check for similar active tasks before creating. When possible duplicates exist, the warning lists each match by task description (falling back to title, then “No description”) and lets you open an existing task, cancel, or create anyway with the duplicates acknowledged.
Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer. History rows are deduplicated by session id even if the initial load and live session updates arrive out of order, and deleting a history entry now waits for the server delete to persist (failures keep the row visible and surface an error instead of silently disappearing until refresh). Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer. History rows are deduplicated by session id even if the initial load and live session updates arrive out of order, and deleting a history entry now waits for the server delete to persist (failures keep the row visible and surface an error instead of silently disappearing until refresh).

View File

@@ -1,5 +1,6 @@
/* === Quick Entry Box === */ /* === Quick Entry Box === */
.quick-entry-box { .quick-entry-box {
position: relative;
padding: 8px 10px; padding: 8px 10px;
background: var(--card); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -11,6 +12,54 @@
border-color: var(--todo); border-color: var(--todo);
} }
.quick-entry-box--drag-over {
border-color: var(--triage);
box-shadow: 0 0 0 var(--space-2xs) color-mix(in srgb, var(--triage) 35%, transparent);
}
.quick-entry-drop-target {
position: absolute;
inset: var(--space-xs);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
padding: var(--space-sm);
border: var(--space-2xs) dashed var(--triage);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--card) 86%, var(--triage));
color: var(--text);
font-size: 0.8125rem;
font-weight: 600;
pointer-events: none;
}
.quick-entry-attach-button {
position: relative;
min-width: calc(var(--space-xl) + var(--space-xs));
gap: var(--space-2xs);
}
.quick-entry-attach-count {
min-width: var(--space-md);
padding: 0 var(--space-2xs);
border-radius: calc(var(--radius) * 4);
background: var(--triage);
color: var(--provider-icon-contrast);
font-size: 0.6875rem;
line-height: 1.2;
font-weight: 700;
}
@media (max-width: 768px) {
.quick-entry-drop-target {
inset: var(--space-2xs);
padding: var(--space-xs);
text-align: center;
}
}
.quick-entry-input { .quick-entry-input {
width: 100%; width: 100%;
padding: 6px 8px; padding: 6px 8px;

View File

@@ -155,6 +155,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const previousProjectIdRef = useRef(projectId); const previousProjectIdRef = useRef(projectId);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]); const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const pendingImagesRef = useRef<PendingImage[]>([]); const pendingImagesRef = useRef<PendingImage[]>([]);
const [isFileDragOver, setIsFileDragOver] = useState(false);
const dragDepthRef = useRef(0);
// Rich creation state (mirrors InlineCreateCard) // Rich creation state (mirrors InlineCreateCard)
const [dependencies, setDependencies] = useState<string[]>([]); const [dependencies, setDependencies] = useState<string[]>([]);
@@ -609,6 +611,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setPendingImages([]); setPendingImages([]);
dragDepthRef.current = 0;
setIsFileDragOver(false);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = "";
} }
@@ -650,7 +654,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
} }
}, [pendingImages, projectId, optionalSteps]); }, [pendingImages, projectId, optionalSteps]);
const handleImageFiles = useCallback((files: FileList | null | undefined) => { const handleImageFiles = useCallback((files: FileList | File[] | null | undefined) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const newImages: PendingImage[] = []; const newImages: PendingImage[] = [];
@@ -666,6 +670,55 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
} }
}, []); }, []);
const isFileDrag = useCallback((dataTransfer: DataTransfer | null) => {
if (!dataTransfer) return false;
return Array.from(dataTransfer.types ?? []).includes("Files");
}, []);
/*
FNXC:QuickAddAttachments 2026-06-30-00:00:
Quick Add uses an icon-only paperclip to keep the action row compact, so the accessible name and title carry the Attach action plus pending-image count. The same image intake path handles file input, paste, and file drag/drop so previews and post-create uploads stay consistent.
*/
const attachLabel = pendingImages.length > 0
? t("tasks.attachImagesCount", "Attach images ({{count}} pending)", { count: pendingImages.length })
: t("tasks.attachImages", "Attach images");
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
dragDepthRef.current += 1;
setIsFileDragOver(true);
}, [isFileDrag]);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsFileDragOver(true);
}, [isFileDrag]);
const clearFileDragState = useCallback(() => {
dragDepthRef.current = 0;
setIsFileDragOver(false);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsFileDragOver(false);
}
}, [isFileDrag]);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
clearFileDragState();
if (isSubmitting) return;
handleImageFiles(e.dataTransfer.files);
}, [clearFileDragState, handleImageFiles, isFileDrag, isSubmitting]);
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => { const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (isSubmitting) return; if (isSubmitting) return;
handleImageFiles(e.clipboardData?.files); handleImageFiles(e.clipboardData?.files);
@@ -1703,7 +1756,21 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
return ( return (
<> <>
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}${singleLine ? " quick-entry--single-line" : ""}`} data-testid="quick-entry-box"> <div
className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}${singleLine ? " quick-entry--single-line" : ""}${isFileDragOver ? " quick-entry-box--drag-over" : ""}`}
data-testid="quick-entry-box"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDragEnd={clearFileDragState}
onDrop={handleDrop}
>
{isFileDragOver && (
<div className="quick-entry-drop-target" data-testid="quick-entry-drop-target" aria-hidden="true">
<Paperclip size={16} aria-hidden="true" />
<span>{t("tasks.dropImagesToAttach", "Drop images to attach")}</span>
</div>
)}
<div className="description-with-refine"> <div className="description-with-refine">
<div className="quick-entry-main-row"> <div className="quick-entry-main-row">
<div className="quick-entry-textarea-wrap"> <div className="quick-entry-textarea-wrap">
@@ -2164,12 +2231,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<button <button
type="button" type="button"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
className="btn btn-sm" className="btn btn-icon btn-sm quick-entry-attach-button"
data-testid="quick-entry-attach" data-testid="quick-entry-attach"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
aria-label={attachLabel}
title={attachLabel}
> >
<Paperclip size={12} style={{ verticalAlign: "middle" }} /> <Paperclip size={12} aria-hidden="true" />
{pendingImages.length > 0 ? t("tasks.attachCount", "Attach ({{count}})", { count: pendingImages.length }) : t("tasks.attach", "Attach")} {pendingImages.length > 0 && (
<span className="quick-entry-attach-count" aria-hidden="true">{pendingImages.length}</span>
)}
</button> </button>
<button <button

View File

@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, act, createEvent } from "@testing-library/react";
import { QuickEntryBox } from "../QuickEntryBox"; import { QuickEntryBox } from "../QuickEntryBox";
import type { Task } from "@fusion/core"; import type { Task } from "@fusion/core";
import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../../api"; import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../../api";
@@ -3112,14 +3112,20 @@ describe("QuickEntryBox", () => {
}); });
describe("image attachments", () => { describe("image attachments", () => {
it("shows Attach as an inline control when expanded", () => { it("shows an icon-only Attach control when expanded", () => {
renderQuickEntryBox({}); renderQuickEntryBox({});
expandQuickEntry(); expandQuickEntry();
expect(screen.getByTestId("quick-entry-attach")).toBeInTheDocument(); const attachButton = screen.getByTestId("quick-entry-attach");
expect(attachButton).toBeInTheDocument();
expect(attachButton).toHaveAccessibleName("Attach images");
expect(attachButton).toHaveAttribute("title", "Attach images");
expect(attachButton.textContent).not.toContain("Attach");
expect(attachButton.querySelector("svg")).toBeTruthy();
expect(attachButton.classList.contains("btn-icon")).toBe(true);
}); });
it("clicking Attach triggers the hidden file input", () => { it("clicking the icon-only Attach control triggers the hidden file input", () => {
renderQuickEntryBox({}); renderQuickEntryBox({});
expandQuickEntry(); expandQuickEntry();
@@ -3197,7 +3203,7 @@ describe("QuickEntryBox", () => {
expect(uploadAttachment).not.toHaveBeenCalled(); expect(uploadAttachment).not.toHaveBeenCalled();
}); });
it("shows pending image count in the inline Attach label", () => { it("preserves pending image count in accessible labels without visible Attach text", () => {
renderQuickEntryBox({}); renderQuickEntryBox({});
expandQuickEntry(); expandQuickEntry();
@@ -3205,7 +3211,117 @@ describe("QuickEntryBox", () => {
const file = new File(["badge"], "badge.png", { type: "image/png" }); const file = new File(["badge"], "badge.png", { type: "image/png" });
fireEvent.change(fileInput, { target: { files: [file] } }); fireEvent.change(fileInput, { target: { files: [file] } });
expect(screen.getByTestId("quick-entry-attach").textContent).toContain("Attach (1)"); const attachButton = screen.getByTestId("quick-entry-attach");
expect(attachButton).toHaveAccessibleName("Attach images (1 pending)");
expect(attachButton).toHaveAttribute("title", "Attach images (1 pending)");
expect(attachButton.textContent).toBe("1");
expect(attachButton.textContent).not.toContain("Attach");
});
it("keeps the icon-only Attach control in the mobile touch target action row", () => {
mockMobileViewport();
renderQuickEntryBox({});
expandQuickEntry();
const attachButton = screen.getByTestId("quick-entry-attach");
expect(attachButton.closest(".quick-entry-actions")).toBeTruthy();
expect(attachButton.classList.contains("btn-icon")).toBe(true);
const touchRule = cssRuleBody(
QUICK_ENTRY_BOX_CSS,
".quick-entry-actions .btn,\n .quick-entry-actions .wf-optional-steps-dropdown-trigger",
);
expect(touchRule).toContain("min-height: calc(var(--space-2xl) + var(--space-xs))");
});
it("shows and clears a Quick Add drop target only for file drags", () => {
renderQuickEntryBox({});
const box = screen.getByTestId("quick-entry-box");
fireEvent.dragEnter(box, { dataTransfer: { types: ["text/plain"], files: [] } });
expect(screen.queryByTestId("quick-entry-drop-target")).toBeNull();
fireEvent.dragEnter(box, { dataTransfer: { types: ["Files"], files: [] } });
expect(screen.getByTestId("quick-entry-drop-target")).toHaveTextContent("Drop images to attach");
expect(box.classList.contains("quick-entry-box--drag-over")).toBe(true);
fireEvent.dragLeave(box, { dataTransfer: { types: ["Files"], files: [] } });
expect(screen.queryByTestId("quick-entry-drop-target")).toBeNull();
expect(box.classList.contains("quick-entry-box--drag-over")).toBe(false);
});
it("prevents browser navigation while dragging files over nested Quick Add controls", () => {
renderQuickEntryBox({});
expandQuickEntry();
const attachButton = screen.getByTestId("quick-entry-attach");
const dragOverEvent = createEvent.dragOver(attachButton);
Object.defineProperty(dragOverEvent, "dataTransfer", {
value: { types: ["Files"], files: [], dropEffect: "none" },
});
const preventDefault = vi.spyOn(dragOverEvent, "preventDefault");
fireEvent(attachButton, dragOverEvent);
expect(preventDefault).toHaveBeenCalled();
expect(screen.getByTestId("quick-entry-drop-target")).toBeInTheDocument();
});
it("adds previews for supported dropped images and rejects unsupported dropped files", () => {
renderQuickEntryBox({});
const box = screen.getByTestId("quick-entry-box");
const image = new File(["image"], "dropped.png", { type: "image/png" });
const text = new File(["text"], "notes.txt", { type: "text/plain" });
fireEvent.dragEnter(box, { dataTransfer: { types: ["Files"], files: [image, text] } });
fireEvent.drop(box, { dataTransfer: { types: ["Files"], files: [image, text] } });
expect(screen.queryByTestId("quick-entry-drop-target")).toBeNull();
expect(screen.getByAltText("dropped.png")).toBeInTheDocument();
expect(screen.queryByAltText("notes.txt")).toBeNull();
});
it("uploads images added by dropping files after task creation", async () => {
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
renderQuickEntryBox({ onCreate });
const box = screen.getByTestId("quick-entry-box");
const textarea = screen.getByTestId("quick-entry-input");
const image = new File(["dropped"], "create-dropped.webp", { type: "image/webp" });
fireEvent.drop(box, { dataTransfer: { types: ["Files"], files: [image] } });
fireEvent.change(textarea, { target: { value: "Create with dropped image" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => {
expect(onCreate).toHaveBeenCalled();
expect(uploadAttachment).toHaveBeenCalledWith(CREATED_TASK.id, image, TEST_PROJECT_ID);
});
});
it("clears the drag target after dragend without expanding collapsed List quick add controls", () => {
renderQuickEntryBox({ singleLine: true, defaultExpanded: false });
const box = screen.getByTestId("quick-entry-box");
const controls = document.getElementById("quick-entry-controls");
expect(controls?.hasAttribute("hidden")).toBe(true);
fireEvent.dragEnter(box, { dataTransfer: { types: ["Files"], files: [] } });
expect(screen.getByTestId("quick-entry-drop-target")).toBeInTheDocument();
fireEvent.dragEnd(box);
expect(screen.queryByTestId("quick-entry-drop-target")).toBeNull();
expect(controls?.hasAttribute("hidden")).toBe(true);
});
it("keeps tokenized drag/drop and icon-only attachment CSS contracts", () => {
const dragRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-box--drag-over");
const dropRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-drop-target");
const attachRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-attach-button");
expect(dragRule).not.toBeNull();
expect(dropRule).not.toBeNull();
expect(attachRule).not.toBeNull();
expect(dragRule).toContain("var(--triage)");
expect(dropRule).toContain("var(--triage)");
expect(dropRule).toContain("pointer-events: none");
expect(attachRule).toContain("min-width: calc(var(--space-xl) + var(--space-xs))");
expect(QUICK_ENTRY_BOX_CSS).toMatch(/@media \(max-width: 768px\) \{[\s\S]*\.quick-entry-drop-target/);
}); });
it("resetForm clears pending images and revokes object URLs", async () => { it("resetForm clears pending images and revokes object URLs", async () => {