feat(FN-1209): add quick entry image attachment flow

- Return the created task from board quick create and propagate the onQuickCreate return type through board/list components
- Add pending image handling in QuickEntryBox with paste support, hidden file input, and an Attach action in the menu
- Render removable pending image previews, include image count in the actions badge, and clean up object URLs on reset/unmount
- Upload pending images after task creation with error toasts for failed uploads, and extend tests for attachment and quick-create behavior
This commit is contained in:
gsxdsm
2026-04-08 16:03:46 -07:00
parent 0e50163a1a
commit 833c7eb15a
7 changed files with 279 additions and 17 deletions

View File

@@ -13,7 +13,7 @@ interface BoardProps {
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<void>;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
onNewTask: () => void;
autoMerge: boolean;
onToggleAutoMerge: () => void;

View File

@@ -22,7 +22,7 @@ interface ColumnProps {
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<void>;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
onNewTask?: () => void;
autoMerge?: boolean;
onToggleAutoMerge?: () => void;

View File

@@ -101,7 +101,7 @@ interface ListViewProps {
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
onNewTask?: () => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<void>;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
availableModels?: ModelInfo[];
favoriteProviders?: string[];
favoriteModels?: string[];

View File

@@ -3,15 +3,21 @@ import { createPortal } from "react-dom";
import type { ToastType } from "../hooks/useToast";
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
import type { ModelInfo, RefinementType, Agent } from "../api";
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents } from "../api";
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save, MoreHorizontal, ChevronDown, ChevronUp, ChevronRight, Bot } from "lucide-react";
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, MoreHorizontal, ChevronDown, ChevronUp, ChevronRight, Bot } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
const STORAGE_KEY = "kb-quick-entry-text";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
interface PendingImage {
file: File;
previewUrl: string;
}
interface QuickEntryBoxProps {
onCreate?: (input: TaskCreateInput) => Promise<void>;
onCreate?: (input: TaskCreateInput) => Promise<Task | void>;
addToast: (message: string, type?: ToastType) => void;
tasks?: Task[];
availableModels?: ModelInfo[];
@@ -87,7 +93,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
// Always starts collapsed — user must explicitly toggle each session
const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const justResetRef = useRef(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const pendingImagesRef = useRef<PendingImage[]>([]);
// Rich creation state (mirrors InlineCreateCard)
const [dependencies, setDependencies] = useState<string[]>([]);
@@ -200,7 +209,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
: selectedModelCount > 0
? `${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: "Models";
const actionSelectionCount = dependencies.length + selectedModelCount + (selectedAgentId ? 1 : 0);
const actionSelectionCount = dependencies.length + selectedModelCount + pendingImages.length + (selectedAgentId ? 1 : 0);
const getModelBadgeLabel = useCallback(
(provider?: string, modelId?: string) => {
@@ -234,10 +243,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setPortalRoot(document.body);
}, []);
// Cleanup on unmount
useEffect(() => {
pendingImagesRef.current = pendingImages;
}, [pendingImages]);
// Cleanup image preview URLs on unmount
useEffect(() => {
return () => {
// No blur timeout to clean up
pendingImagesRef.current.forEach((img) => URL.revokeObjectURL(img.previewUrl));
};
}, []);
@@ -350,6 +363,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}, [showAgentPicker]);
const resetForm = useCallback(() => {
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setPendingImages([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setDescription("");
setDependencies([]);
setSelectedAgentId(null);
@@ -379,7 +398,38 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
if (typeof window !== "undefined") {
removeScopedItem(STORAGE_KEY, projectId);
}
}, [projectId]);
}, [pendingImages, projectId]);
const handleImageFiles = useCallback((files: FileList | null | undefined) => {
if (!files || files.length === 0) return;
const newImages: PendingImage[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
newImages.push({ file, previewUrl: URL.createObjectURL(file) });
}
}
if (newImages.length > 0) {
setPendingImages((prev) => [...prev, ...newImages]);
}
}, []);
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (isSubmitting) return;
handleImageFiles(e.clipboardData?.files);
}, [handleImageFiles, isSubmitting]);
const removeImage = useCallback((index: number) => {
setPendingImages((prev) => {
const removed = prev[index];
if (removed) {
URL.revokeObjectURL(removed.previewUrl);
}
return prev.filter((_, i) => i !== index);
});
}, []);
const handleSubmit = useCallback(async () => {
const trimmed = description.trim();
@@ -387,7 +437,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setIsSubmitting(true);
try {
await onCreate({
const createdTask = await onCreate({
description: trimmed,
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
@@ -400,6 +450,20 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
planningModelId: hasPlanningOverride ? planningModelId : undefined,
});
if (createdTask && pendingImages.length > 0) {
const failures: string[] = [];
for (const pendingImage of pendingImages) {
try {
await uploadAttachment(createdTask.id, pendingImage.file, projectId);
} catch {
failures.push(pendingImage.file.name);
}
}
if (failures.length > 0) {
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
}
}
// Clear input for rapid entry
resetForm();
// Note: Focus restoration is handled by useEffect when isSubmitting becomes false
@@ -424,6 +488,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
hasPlanningOverride,
planningProvider,
planningModelId,
pendingImages,
projectId,
addToast,
resetForm,
]);
@@ -932,6 +998,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isSubmitting || isDisabled}
@@ -1057,6 +1124,25 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
</div>
</div>
)}
{pendingImages.length > 0 && (
<div className="inline-create-previews">
{pendingImages.map((img, index) => (
<div key={img.previewUrl} className="inline-create-preview">
<img src={img.previewUrl} alt={img.file.name} />
<button
type="button"
className="inline-create-preview-remove"
onClick={() => removeImage(index)}
disabled={isSubmitting}
title="Remove image"
data-testid={`quick-entry-preview-remove-${index}`}
>
×
</button>
</div>
))}
</div>
)}
<div className="quick-entry-controls-left">
<div
className="quick-entry-actions-wrap dep-trigger-wrap"
@@ -1112,6 +1198,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
<Link size={12} style={{ verticalAlign: "middle" }} />
{dependencies.length > 0 ? `${dependencies.length} deps` : "Deps"}
</button>
<button
type="button"
className="btn btn-sm"
data-testid="quick-entry-actions-attach"
onClick={() => {
setIsActionsMenuOpen(false);
setActionsMenuPosition(null);
fileInputRef.current?.click();
}}
>
<Paperclip size={12} style={{ verticalAlign: "middle" }} />
{pendingImages.length > 0 ? `Attach (${pendingImages.length})` : "Attach"}
</button>
<button
type="button"
className="btn btn-sm"
@@ -1376,6 +1475,18 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
portalRoot,
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
style={{ display: "none" }}
onChange={(e) => {
handleImageFiles(e.target.files);
e.currentTarget.value = "";
}}
data-testid="quick-entry-file-input"
/>
<div className="quick-entry-hint">
Enter to create · Esc to cancel
</div>

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { QuickEntryBox } from "../QuickEntryBox";
import type { Task } from "@fusion/core";
import { fetchSettings, fetchAgents } from "../../api";
import { fetchSettings, fetchAgents, uploadAttachment } from "../../api";
import { scopedKey } from "../../utils/projectStorage";
const MOCK_MODELS = [
@@ -25,6 +25,19 @@ const MOCK_MODELS = [
const TEST_PROJECT_ID = "proj-123";
const QUICK_ENTRY_STORAGE_KEY = scopedKey("kb-quick-entry-text", TEST_PROJECT_ID);
const CREATED_TASK: Task = {
id: "FN-999",
title: "Created task",
description: "Created task description",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-04-08T00:00:00Z",
updatedAt: "2026-04-08T00:00:00Z",
};
const mockTasks: Task[] = [
{
id: "FN-001",
@@ -83,12 +96,14 @@ vi.mock("../../api", () => ({
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
fetchAgents: vi.fn().mockResolvedValue([]),
uploadAttachment: vi.fn().mockResolvedValue({}),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
Link: () => null,
Paperclip: () => null,
Brain: () => null,
Lightbulb: () => null,
ListTree: () => null,
@@ -190,9 +205,23 @@ function clickSaveFromActions() {
describe("QuickEntryBox", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
localStorage.clear();
vi.mocked(fetchAgents).mockResolvedValue([]);
vi.mocked(uploadAttachment).mockResolvedValue({} as any);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
writable: true,
value: vi.fn((file: Blob | MediaSource) => `blob:${(file as File).name ?? "mock"}`),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
writable: true,
value: vi.fn(),
});
});
afterEach(() => {
@@ -1147,6 +1176,126 @@ describe("QuickEntryBox", () => {
});
});
describe("image attachments", () => {
it("shows Attach in the actions dropdown", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsMenu();
expect(screen.getByTestId("quick-entry-actions-attach")).toBeInTheDocument();
});
it("clicking Attach triggers the hidden file input", () => {
renderQuickEntryBox({});
expandQuickEntry();
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, "click");
openActionsMenu();
fireEvent.click(screen.getByTestId("quick-entry-actions-attach"));
expect(clickSpy).toHaveBeenCalled();
});
it("adds a preview when an image is pasted", () => {
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
const file = new File(["image-bytes"], "pasted.png", { type: "image/png" });
fireEvent.paste(textarea, { clipboardData: { files: [file] } });
expect(screen.getByAltText("pasted.png")).toBeInTheDocument();
});
it("removes pending image previews", () => {
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
const file = new File(["image-bytes"], "remove.png", { type: "image/png" });
fireEvent.paste(textarea, { clipboardData: { files: [file] } });
expect(screen.getByAltText("remove.png")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("quick-entry-preview-remove-0"));
expect(screen.queryByAltText("remove.png")).toBeNull();
});
it("uploads each pending image after task creation", async () => {
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
renderQuickEntryBox({ onCreate });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
const fileA = new File(["a"], "a.png", { type: "image/png" });
const fileB = new File(["b"], "b.png", { type: "image/png" });
fireEvent.change(textarea, { target: { value: "Create with images" } });
fireEvent.change(fileInput, { target: { files: [fileA, fileB] } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => {
expect(onCreate).toHaveBeenCalled();
expect(uploadAttachment).toHaveBeenCalledTimes(2);
});
expect(uploadAttachment).toHaveBeenCalledWith(CREATED_TASK.id, fileA, TEST_PROJECT_ID);
expect(uploadAttachment).toHaveBeenCalledWith(CREATED_TASK.id, fileB, TEST_PROJECT_ID);
});
it("does not upload attachments when no pending images exist", async () => {
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
renderQuickEntryBox({ onCreate });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Create without images" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => {
expect(onCreate).toHaveBeenCalled();
});
expect(uploadAttachment).not.toHaveBeenCalled();
});
it("includes pending image count in the actions badge", () => {
renderQuickEntryBox({});
expandQuickEntry();
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
const file = new File(["badge"], "badge.png", { type: "image/png" });
fireEvent.change(fileInput, { target: { files: [file] } });
expect(screen.getByTestId("quick-entry-actions-badge").textContent).toBe("1");
});
it("resetForm clears pending images and revokes object URLs", async () => {
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
renderQuickEntryBox({ onCreate });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
const file = new File(["reset"], "reset.png", { type: "image/png" });
fireEvent.change(textarea, { target: { value: "Create and reset" } });
fireEvent.change(fileInput, { target: { files: [file] } });
expect(screen.getByAltText("reset.png")).toBeInTheDocument();
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => {
expect(onCreate).toHaveBeenCalled();
expect(screen.queryByAltText("reset.png")).toBeNull();
});
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:reset.png");
});
});
describe("State sync between isExpanded and isDisclosureExpanded", () => {
it("focus then toggle shows controls without collapsing textarea", () => {
renderQuickEntryBox();

View File

@@ -35,16 +35,18 @@ describe("useTaskHandlers", () => {
vi.clearAllMocks();
});
it("handleBoardQuickCreate calls createTask with triage column", async () => {
it("handleBoardQuickCreate calls createTask with triage column and returns task", async () => {
const options = createOptions();
const { result } = renderHook(() => useTaskHandlers(options));
const input: TaskCreateInput = { description: "Do work" };
let created: Task | null = null;
await act(async () => {
await result.current.handleBoardQuickCreate(input);
created = await result.current.handleBoardQuickCreate(input);
});
expect(options.createTask).toHaveBeenCalledWith({ description: "Do work", column: "triage" });
expect(created).toEqual(CREATED_TASK);
});
it("handleModalCreate calls createTask with triage column and returns task", async () => {

View File

@@ -11,7 +11,7 @@ interface UseTaskHandlersOptions {
}
export interface UseTaskHandlersResult {
handleBoardQuickCreate: (input: TaskCreateInput) => Promise<void>;
handleBoardQuickCreate: (input: TaskCreateInput) => Promise<Task>;
handleModalCreate: (input: TaskCreateInput) => Promise<Task>;
handlePlanningTaskCreated: (task: Task) => void;
handlePlanningTasksCreated: (tasks: Task[]) => void;
@@ -29,8 +29,8 @@ export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandler
} = options;
const handleBoardQuickCreate = useCallback(
async (input: TaskCreateInput): Promise<void> => {
await createTask({ ...input, column: "triage" });
async (input: TaskCreateInput): Promise<Task> => {
return createTask({ ...input, column: "triage" });
},
[createTask],
);