feat(FN-1381): add Send Back dropdown for in-progress tasks
- Add Send Back dropdown to TaskCard showing for in-progress tasks with available target columns - Thread onMoveTask prop through Column component to BoardView - Terminate agent sessions when tasks move away from in-progress column - Add executor tests for move-away session termination - Add TaskCard send-back UI tests with dropdown visibility and interaction verification
This commit is contained in:
@@ -253,6 +253,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
onMoveTask={onMoveTask}
|
||||
/>
|
||||
))}
|
||||
{shouldPaginate && hiddenTaskCount > 0 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
|
||||
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@fusion/core";
|
||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
import { pickPreferredBadge } from "./TaskCardBadge";
|
||||
@@ -91,6 +92,8 @@ interface TaskCardProps {
|
||||
taskStuckTimeoutMs?: number;
|
||||
/** Called when user clicks the mission badge on a task card. */
|
||||
onOpenMission?: (missionId: string) => void;
|
||||
/** Called when user moves a task to a different column from the card. */
|
||||
onMoveTask?: (id: string, column: Column) => Promise<Task>;
|
||||
}
|
||||
|
||||
function areTaskBadgeInfosEqual(
|
||||
@@ -136,6 +139,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
||||
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
|
||||
previous.onOpenMission === next.onOpenMission &&
|
||||
previous.onMoveTask === next.onMoveTask &&
|
||||
previousTask.id === nextTask.id &&
|
||||
previousTask.title === nextTask.title &&
|
||||
previousTask.description === nextTask.description &&
|
||||
@@ -182,6 +186,7 @@ function TaskCardComponent({
|
||||
onOpenDetailWithTab,
|
||||
taskStuckTimeoutMs,
|
||||
onOpenMission,
|
||||
onMoveTask,
|
||||
}: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
@@ -194,10 +199,12 @@ function TaskCardComponent({
|
||||
);
|
||||
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
||||
const [agentName, setAgentName] = useState<string | null>(null);
|
||||
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
||||
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const touchOpenHandledRef = useRef(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const sendBackRef = useRef<HTMLDivElement>(null);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
|
||||
|
||||
@@ -215,6 +222,18 @@ function TaskCardComponent({
|
||||
setEditDescription(task.description || "");
|
||||
}, [task.id, task.description]);
|
||||
|
||||
// Close send-back menu on outside click
|
||||
useEffect(() => {
|
||||
if (!showSendBackMenu) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (sendBackRef.current && !sendBackRef.current.contains(e.target as Node)) {
|
||||
setShowSendBackMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, [showSendBackMenu]);
|
||||
|
||||
// Fetch mission title when missionId is set
|
||||
useEffect(() => {
|
||||
if (!task.missionId) {
|
||||
@@ -603,6 +622,23 @@ function TaskCardComponent({
|
||||
}
|
||||
}, [task.missionId, onOpenMission]);
|
||||
|
||||
const handleSendBackClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowSendBackMenu((current) => !current);
|
||||
}, []);
|
||||
|
||||
const handleSendBackOptionClick = useCallback((e: React.MouseEvent, column: Column) => {
|
||||
e.stopPropagation();
|
||||
setShowSendBackMenu(false);
|
||||
if (!onMoveTask) return;
|
||||
|
||||
void onMoveTask(task.id, column).then(() => {
|
||||
addToast(`Moved ${task.id} to ${COLUMN_LABELS[column]}`, "success");
|
||||
}).catch((err: any) => {
|
||||
addToast(`Failed to move ${task.id}: ${err.message}`, "error");
|
||||
});
|
||||
}, [addToast, onMoveTask, task.id]);
|
||||
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
|
||||
|
||||
if (isEditing) {
|
||||
@@ -729,6 +765,37 @@ function TaskCardComponent({
|
||||
Unarchive
|
||||
</button>
|
||||
)}
|
||||
{task.column === "in-progress" && onMoveTask && (
|
||||
<div className="card-send-back" ref={sendBackRef}>
|
||||
<button
|
||||
className="card-send-back-btn"
|
||||
onClick={handleSendBackClick}
|
||||
title="Send back"
|
||||
aria-label="Send back"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSendBackMenu}
|
||||
>
|
||||
Send back
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
{showSendBackMenu && (
|
||||
<div className="card-send-back-menu" role="menu">
|
||||
{VALID_TRANSITIONS["in-progress"]
|
||||
.filter((col) => col !== "in-review")
|
||||
.map((col) => (
|
||||
<button
|
||||
key={col}
|
||||
className="card-send-back-menu-item"
|
||||
role="menuitem"
|
||||
onClick={(e) => handleSendBackOptionClick(e, col)}
|
||||
>
|
||||
{COLUMN_LABELS[col]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{task.size && (
|
||||
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
|
||||
{task.size}
|
||||
|
||||
@@ -3488,3 +3488,143 @@ describe("TaskCard agent badge", () => {
|
||||
expect(screen.queryByTestId("bot-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard send-back functionality", () => {
|
||||
const createTask = (overrides: Partial<Task> = {}): Task => ({
|
||||
id: "FN-001",
|
||||
description: "Test task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
} as Task);
|
||||
|
||||
it("renders send-back button when task is in-progress and onMoveTask is provided", () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||
const task = createTask({ column: "in-progress" });
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /send back/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render send-back button when task is in todo", () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||
const task = createTask({ column: "todo" });
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /send back/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render send-back button when task is in in-review", () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||
const task = createTask({ column: "in-review" });
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /send back/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render send-back button when onMoveTask is not provided", () => {
|
||||
const task = createTask({ column: "in-progress" });
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /send back/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles dropdown menu when send-back button is clicked", () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||
const task = createTask({ column: "in-progress" });
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||
|
||||
// Initially, dropdown is not visible
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
|
||||
// Click the send-back button
|
||||
const btn = screen.getByRole("button", { name: /send back/i });
|
||||
fireEvent.click(btn);
|
||||
|
||||
// Dropdown should now be visible
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dropdown shows Todo and Triage options but not In Review", () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||
const task = createTask({ column: "in-progress" });
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||
|
||||
// Open the dropdown
|
||||
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
|
||||
|
||||
// Check menu is visible
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu).toBeInTheDocument();
|
||||
|
||||
// Should show Todo and Triage
|
||||
expect(screen.getByRole("menuitem", { name: /todo/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: /triage/i })).toBeInTheDocument();
|
||||
|
||||
// Should NOT show In Review
|
||||
expect(screen.queryByRole("menuitem", { name: /in review/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a dropdown option calls onMoveTask with correct column and closes menu", async () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||
const addToast = vi.fn();
|
||||
const task = createTask({ column: "in-progress" });
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={addToast} onMoveTask={onMoveTask} />);
|
||||
|
||||
// Open the dropdown
|
||||
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
|
||||
|
||||
// Click "Todo" option
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /todo/i }));
|
||||
|
||||
// Should have called onMoveTask
|
||||
await waitFor(() => {
|
||||
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
});
|
||||
|
||||
// Dropdown should be closed
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
|
||||
// Toast should have been shown
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Moved FN-001 to Todo", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when onMoveTask fails", async () => {
|
||||
const onMoveTask = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
const addToast = vi.fn();
|
||||
const task = createTask({ column: "in-progress" });
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={addToast} onMoveTask={onMoveTask} />);
|
||||
|
||||
// Open the dropdown and click "Triage"
|
||||
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /triage/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Failed to move FN-001"), "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking outside dropdown closes it", () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||
const task = createTask({ column: "in-progress" });
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||
|
||||
// Open the dropdown
|
||||
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
|
||||
// Click outside (on the card itself, not inside the send-back dropdown)
|
||||
fireEvent.click(document.querySelector(".card")!);
|
||||
|
||||
// Dropdown should be closed
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user