feat(HAI-042): add file attachment support for tasks
- Expand MIME type support for text file uploads - Add --attach flag to CLI task create command - Surface attachments to triage agent with image content support - Reference attachments in executor prompt for task execution context - Add drag-and-drop file upload on dashboard task cards
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import type { Task } from "@hai/core";
|
||||
|
||||
@@ -9,6 +9,14 @@ vi.mock("lucide-react", () => ({
|
||||
Clock: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
}));
|
||||
|
||||
import { uploadAttachment } from "../api";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "HAI-001",
|
||||
@@ -63,4 +71,89 @@ describe("TaskCard", () => {
|
||||
);
|
||||
expect(container.querySelector(".card-status-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows drop indicator on file dragover and removes on dragleave", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
// Simulate file dragover
|
||||
fireEvent.dragOver(card, {
|
||||
dataTransfer: { types: ["Files"], dropEffect: "none" },
|
||||
});
|
||||
expect(card.classList.contains("file-drop-target")).toBe(true);
|
||||
|
||||
// Simulate dragleave
|
||||
fireEvent.dragLeave(card, {
|
||||
dataTransfer: { types: ["Files"] },
|
||||
});
|
||||
expect(card.classList.contains("file-drop-target")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show drop indicator for non-file drag", () => {
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
// Simulate card dragover (not files)
|
||||
fireEvent.dragOver(card, {
|
||||
dataTransfer: { types: ["text/plain"], dropEffect: "none" },
|
||||
});
|
||||
expect(card.classList.contains("file-drop-target")).toBe(false);
|
||||
});
|
||||
|
||||
it("calls uploadAttachment on file drop", async () => {
|
||||
const mockUpload = vi.mocked(uploadAttachment);
|
||||
mockUpload.mockResolvedValue({
|
||||
filename: "abc-test.png",
|
||||
originalName: "test.png",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
const addToast = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
const file = new File(["content"], "test.png", { type: "image/png" });
|
||||
fireEvent.drop(card, {
|
||||
dataTransfer: { types: ["Files"], files: [file] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpload).toHaveBeenCalledWith("HAI-001", file);
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Attached test.png"),
|
||||
"success",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when upload fails", async () => {
|
||||
const mockUpload = vi.mocked(uploadAttachment);
|
||||
mockUpload.mockRejectedValue(new Error("Upload failed"));
|
||||
const addToast = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
|
||||
);
|
||||
const card = container.querySelector(".card")!;
|
||||
|
||||
const file = new File(["content"], "bad.png", { type: "image/png" });
|
||||
fireEvent.drop(card, {
|
||||
dataTransfer: { types: ["Files"], files: [file] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to attach bad.png"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Link, Clock } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column } from "@hai/core";
|
||||
import { fetchTaskDetail } from "../api";
|
||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
@@ -31,6 +31,7 @@ interface TaskCardProps {
|
||||
|
||||
export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent) => {
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
@@ -42,6 +43,42 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
|
||||
setDragging(false);
|
||||
}, []);
|
||||
|
||||
const isFileDrag = useCallback((e: React.DragEvent) => {
|
||||
return e.dataTransfer.types.includes("Files");
|
||||
}, []);
|
||||
|
||||
const handleFileDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setFileDragOver(true);
|
||||
}, [isFileDrag]);
|
||||
|
||||
const handleFileDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFileDragOver(false);
|
||||
}, [isFileDrag]);
|
||||
|
||||
const handleFileDrop = useCallback(async (e: React.DragEvent) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFileDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
for (const file of files) {
|
||||
try {
|
||||
await uploadAttachment(task.id, file);
|
||||
addToast(`Attached ${file.name} to ${task.id}`, "success");
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to attach ${file.name}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
}, [task.id, isFileDrag, addToast]);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id);
|
||||
@@ -53,7 +90,7 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
|
||||
|
||||
const isFailed = task.status === "failed";
|
||||
const isAgentActive = !queued && !isFailed && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}`;
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${fileDragOver ? " file-drop-target" : ""}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -62,6 +99,9 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
|
||||
draggable={!queued}
|
||||
onDragStart={queued ? undefined : handleDragStart}
|
||||
onDragEnd={queued ? undefined : handleDragEnd}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="card-header">
|
||||
|
||||
@@ -206,6 +206,10 @@ html, body {
|
||||
}
|
||||
.card:active { cursor: grabbing; }
|
||||
.card.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
.card.file-drop-target {
|
||||
border: 2px dashed var(--todo);
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Agent-active glow: animated border glow when an agent is actively working on a task.
|
||||
Uses the in-progress column color to stay consistent with the theme. */
|
||||
|
||||
Reference in New Issue
Block a user