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:
Dustin Byrne
2026-03-26 00:06:41 -04:00
parent afcd074caa
commit 123c481dfc
13 changed files with 592 additions and 27 deletions

View File

@@ -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",
);
});
});
});

View File

@@ -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">

View File

@@ -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. */