feat(KB-051): add duplicate task button in task detail modal
- Add duplicateTask action to useTasks hook for creating task copies - Add Duplicate button to TaskDetailModal with visual styling - Wire up duplicateTask action through App.tsx task handlers - Add comprehensive tests for duplicate button functionality - Include changeset for the new duplicate task feature
This commit is contained in:
@@ -36,7 +36,7 @@ function AppInner() {
|
||||
return "board";
|
||||
});
|
||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask } = useTasks();
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask } = useTasks();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
@@ -185,6 +185,7 @@ function AppInner() {
|
||||
onDeleteTask={deleteTask}
|
||||
onMergeTask={mergeTask}
|
||||
onRetryTask={retryTask}
|
||||
onDuplicateTask={duplicateTask}
|
||||
addToast={addToast}
|
||||
githubTokenConfigured={githubTokenConfigured}
|
||||
/>
|
||||
|
||||
@@ -56,6 +56,7 @@ interface TaskDetailModalProps {
|
||||
onDeleteTask: (id: string) => Promise<Task>;
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
githubTokenConfigured?: boolean;
|
||||
}
|
||||
@@ -73,6 +74,7 @@ export function TaskDetailModal({
|
||||
onDeleteTask,
|
||||
onMergeTask,
|
||||
onRetryTask,
|
||||
onDuplicateTask,
|
||||
addToast,
|
||||
githubTokenConfigured,
|
||||
}: TaskDetailModalProps) {
|
||||
@@ -158,6 +160,18 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [task.id, onRetryTask, onClose, addToast]);
|
||||
|
||||
const handleDuplicate = useCallback(async () => {
|
||||
if (!onDuplicateTask) return;
|
||||
if (!confirm(`Duplicate ${task.id}? This will create a new task in Triage with the same description and prompt.`)) return;
|
||||
try {
|
||||
const newTask = await onDuplicateTask(task.id);
|
||||
onClose();
|
||||
addToast(`Duplicated ${task.id} → ${newTask.id}`, "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, onDuplicateTask, onClose, addToast]);
|
||||
|
||||
const handleTogglePause = useCallback(async () => {
|
||||
try {
|
||||
if (task.paused) {
|
||||
@@ -661,6 +675,11 @@ export function TaskDetailModal({
|
||||
<button className="btn btn-danger btn-sm" onClick={handleDelete}>
|
||||
Delete
|
||||
</button>
|
||||
{onDuplicateTask && (
|
||||
<button className="btn btn-sm" onClick={handleDuplicate}>
|
||||
Duplicate
|
||||
</button>
|
||||
)}
|
||||
{task.status === "failed" && onRetryTask && (
|
||||
<button className="btn btn-warning btn-sm" onClick={handleRetry}>
|
||||
Retry
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock("../../api", () => ({
|
||||
requestSpecRevision: vi.fn().mockResolvedValue({}),
|
||||
approvePlan: vi.fn().mockResolvedValue({}),
|
||||
rejectPlan: vi.fn().mockResolvedValue({}),
|
||||
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgentLogs", () => ({
|
||||
@@ -1807,4 +1808,179 @@ describe("TaskDetailModal", () => {
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Duplicate button", () => {
|
||||
it("renders Duplicate button in modal actions when onDuplicateTask is provided", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onDuplicateTask={vi.fn()}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Duplicate")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render Duplicate button when onDuplicateTask is not provided", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Duplicate")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking Duplicate shows confirmation dialog", () => {
|
||||
const originalConfirm = window.confirm;
|
||||
window.confirm = vi.fn(() => false);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "KB-001" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onDuplicateTask={vi.fn()}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
"Duplicate KB-001? This will create a new task in Triage with the same description and prompt."
|
||||
);
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
|
||||
it("confirming duplicate calls onDuplicateTask and closes modal", async () => {
|
||||
const originalConfirm = window.confirm;
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
const mockDuplicate = vi.fn().mockResolvedValue({ id: "KB-002" } as Task);
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "KB-001" })}
|
||||
onClose={onClose}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onDuplicateTask={mockDuplicate}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDuplicate).toHaveBeenCalledWith("KB-001");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
|
||||
it("successful duplicate shows success toast with new task ID", async () => {
|
||||
const originalConfirm = window.confirm;
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
const mockDuplicate = vi.fn().mockResolvedValue({ id: "KB-002" } as Task);
|
||||
const addToast = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "KB-001" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onDuplicateTask={mockDuplicate}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Duplicated KB-001 → KB-002", "success");
|
||||
});
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
|
||||
it("cancelling confirmation does not call onDuplicateTask", () => {
|
||||
const originalConfirm = window.confirm;
|
||||
window.confirm = vi.fn(() => false);
|
||||
|
||||
const mockDuplicate = vi.fn().mockResolvedValue({ id: "KB-002" } as Task);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "KB-001" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onDuplicateTask={mockDuplicate}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
expect(mockDuplicate).not.toHaveBeenCalled();
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
|
||||
it("shows error toast when duplicate fails", async () => {
|
||||
const originalConfirm = window.confirm;
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
const mockDuplicate = vi.fn().mockRejectedValue(new Error("Duplicate failed"));
|
||||
const addToast = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "KB-001" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onDuplicateTask={mockDuplicate}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Duplicate failed", "error");
|
||||
});
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,10 @@ export function useTasks() {
|
||||
return api.retryTask(id);
|
||||
}, []);
|
||||
|
||||
const duplicateTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return api.duplicateTask(id);
|
||||
}, []);
|
||||
|
||||
const updateTask = useCallback(async (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
@@ -152,5 +156,5 @@ export function useTasks() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask };
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user