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:
gsxdsm
2026-04-09 13:00:37 -07:00
parent 3ea53f1aa2
commit 7ab96cc876
8 changed files with 501 additions and 2 deletions

View File

@@ -253,6 +253,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
/>
))}
{shouldPaginate && hiddenTaskCount > 0 && (

View File

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

View File

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