feat(teams): add multi-select checkboxes and bulk delete to task list

Add checkbox column to task list view for selecting terminal-status
tasks (completed/failed/cancelled). Header checkbox supports select-all
with indeterminate state. Bulk action bar appears on selection with
delete button that opens ConfirmDeleteDialog requiring user to type
"delete" to confirm.

Backend: new teams.tasks.delete-bulk RPC method with DeleteTasks batch
SQL (DELETE ... WHERE id = ANY($1) RETURNING id). Broadcasts delete
event per task for real-time UI sync.

i18n: added bulk action keys for en/vi/zh.
This commit is contained in:
viettranx committed 2026-03-17 15:10:43 +07:00
1 parent aeadb20ba7
commit ae3e5cebcf
12 files changed
+240 -11

No files matched your search

+65
View File
@@ -53,6 +53,7 @@ func (m *TeamsMethods) RegisterTasks(router *gateway.MethodRouter) {
router.Register(protocol.MethodTeamsTaskEvents, m.handleTaskEvents)
router.Register(protocol.MethodTeamsTaskCreate, m.handleTaskCreate)
router.Register(protocol.MethodTeamsTaskDelete, m.handleTaskDelete)
router.Register(protocol.MethodTeamsTaskDeleteBulk, m.handleTaskDeleteBulk)
router.Register(protocol.MethodTeamsTaskAssign, m.handleTaskAssign)
}
@@ -651,6 +652,70 @@ func (m *TeamsMethods) handleTaskDelete(ctx context.Context, client *gateway.Cli
}
}
// --- Task Delete Bulk (hard-delete multiple terminal-status tasks) ---
type taskDeleteBulkParams struct {
TeamID string `json:"teamId"`
TaskIDs []string `json:"taskIds"`
}
func (m *TeamsMethods) handleTaskDeleteBulk(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params taskDeleteBulkParams
locale, ok := m.parseTaskParams(ctx, client, req, &params)
if !ok {
return
}
teamID, err := uuid.Parse(params.TeamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
return
}
if len(params.TaskIDs) == 0 {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "taskIds is required"))
return
}
taskUUIDs := make([]uuid.UUID, 0, len(params.TaskIDs))
for _, raw := range params.TaskIDs {
id, err := uuid.Parse(raw)
if err != nil {
continue // skip invalid IDs
}
taskUUIDs = append(taskUUIDs, id)
}
if len(taskUUIDs) == 0 {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "no valid taskIds"))
return
}
deleted, err := m.teamStore.DeleteTasks(ctx, taskUUIDs, teamID)
if err != nil {
slog.Warn("teams.tasks.delete-bulk failed", "team_id", teamID, "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
return
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"deleted": len(deleted),
}))
// Broadcast delete event per task for real-time UI sync.
if m.msgBus != nil {
for _, id := range deleted {
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskDeleted, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: id.String(),
UserID: client.UserID(),
Channel: "dashboard",
Timestamp: taskNowUTC(),
ActorType: "human",
ActorID: client.UserID(),
}))
}
}
}
// dispatchTaskToAgent publishes a teammate-style inbound message so the
// gateway consumer picks it up and runs the assigned agent, then auto-completes
// the task on success or auto-fails on error.
+24
View File
@@ -261,6 +261,30 @@ func (s *PGTeamStore) DeleteTask(ctx context.Context, taskID, teamID uuid.UUID)
return nil
}
func (s *PGTeamStore) DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, teamID uuid.UUID) ([]uuid.UUID, error) {
if len(taskIDs) == 0 {
return nil, nil
}
rows, err := s.db.QueryContext(ctx,
`DELETE FROM team_tasks
WHERE id = ANY($1) AND team_id = $2 AND status IN ('completed','failed','cancelled')
RETURNING id`,
taskIDs, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var deleted []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return deleted, err
}
deleted = append(deleted, id)
}
return deleted, rows.Err()
}
func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) {
var tasks []store.TeamTaskData
for rows.Next() {
+3
View File
@@ -327,6 +327,9 @@ type TeamStore interface {
// DeleteTask permanently removes a terminal-status task (completed/failed/cancelled).
// Returns ErrTaskNotFound if the task does not exist or is not in a terminal status.
DeleteTask(ctx context.Context, taskID, teamID uuid.UUID) error
// DeleteTasks permanently removes multiple terminal-status tasks in a single query.
// Returns the list of IDs that were actually deleted.
DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, teamID uuid.UUID) ([]uuid.UUID, error)
// ClaimTask atomically transitions a task from pending to in_progress.
// Only one agent can claim a given task (row-level lock, race-safe).
+3 -2
View File
@@ -114,8 +114,9 @@ const (
MethodTeamsTaskComments = "teams.tasks.comments"
MethodTeamsTaskEvents = "teams.tasks.events"
MethodTeamsTaskCreate = "teams.tasks.create"
MethodTeamsTaskDelete = "teams.tasks.delete"
MethodTeamsTaskAssign = "teams.tasks.assign"
MethodTeamsTaskDelete = "teams.tasks.delete"
MethodTeamsTaskDeleteBulk = "teams.tasks.delete-bulk"
MethodTeamsTaskAssign = "teams.tasks.assign"
MethodTeamsMembersAdd = "teams.members.add"
MethodTeamsMembersRemove = "teams.members.remove"
MethodTeamsUpdate = "teams.update"
+1
View File
@@ -140,6 +140,7 @@ export const Methods = {
TEAMS_TASK_EVENTS: "teams.tasks.events",
TEAMS_TASK_CREATE: "teams.tasks.create",
TEAMS_TASK_DELETE: "teams.tasks.delete",
TEAMS_TASK_DELETE_BULK: "teams.tasks.delete-bulk",
TEAMS_TASK_ASSIGN: "teams.tasks.assign",
TEAMS_MEMBERS_ADD: "teams.members.add",
TEAMS_MEMBERS_REMOVE: "teams.members.remove",
+4
View File
@@ -120,6 +120,10 @@
},
"delete": "Delete",
"deleteConfirm": "Delete this task? This cannot be undone.",
"selected": "{{count}} selected",
"deleteSelected": "Delete selected",
"deleteBulkTitle": "Delete tasks",
"deleteBulkConfirm": "This will permanently delete {{count}} task(s). Type \"delete\" to confirm.",
"badges": {
"awaitingReply": "awaiting reply"
}
+4
View File
@@ -120,6 +120,10 @@
},
"delete": "Xoá",
"deleteConfirm": "Xoá task này? Không thể hoàn tác.",
"selected": "Đã chọn {{count}}",
"deleteSelected": "Xoá đã chọn",
"deleteBulkTitle": "Xoá nhiều task",
"deleteBulkConfirm": "Thao tác này sẽ xoá vĩnh viễn {{count}} task. Nhập \"delete\" để xác nhận.",
"badges": {
"awaitingReply": "chờ trả lời"
}
+4
View File
@@ -120,6 +120,10 @@
},
"delete": "删除",
"deleteConfirm": "删除此任务?此操作无法撤消。",
"selected": "已选择 {{count}} 项",
"deleteSelected": "删除所选",
"deleteBulkTitle": "批量删除任务",
"deleteBulkConfirm": "此操作将永久删除 {{count}} 个任务。输入「delete」确认。",
"badges": {
"awaitingReply": "等待回复"
}
@@ -24,12 +24,13 @@ interface BoardContainerProps {
getTeamTasks: (teamId: string, status?: string, channel?: string, chatId?: string) => Promise<{ tasks: TeamTaskData[]; count: number }>;
getTaskDetail: (teamId: string, taskId: string) => Promise<{ task: TeamTaskData; comments: TeamTaskComment[]; events: TeamTaskEvent[]; attachments: TeamTaskAttachment[] }>;
deleteTask?: (teamId: string, taskId: string) => Promise<void>;
deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise<number>;
onWorkspace?: () => void;
}
export const BoardContainer = memo(function BoardContainer({
teamId, members, scopes, isTeamV2,
getTeamTasks, getTaskDetail, deleteTask, onWorkspace,
getTeamTasks, getTaskDetail, deleteTask, deleteTasksBulk, onWorkspace,
}: BoardContainerProps) {
const { t } = useTranslation("teams");
const viewMode = useBoardStore((s) => s.viewMode);
@@ -164,6 +165,7 @@ export const BoardContainer = memo(function BoardContainer({
emojiLookup={emojiLookup}
getTaskDetail={getTaskDetail}
deleteTask={deleteTask}
deleteTasksBulk={deleteTasksBulk}
/>
)}
</div>
+9 -1
View File
@@ -153,6 +153,14 @@ export function useTeams() {
[ws],
);
const deleteTasksBulk = useCallback(
async (teamId: string, taskIds: string[]) => {
const res = await ws.call<{ deleted: number }>(Methods.TEAMS_TASK_DELETE_BULK, { teamId, taskIds });
return res.deleted;
},
[ws],
);
const assignTask = useCallback(
async (teamId: string, taskId: string, agentId: string) => {
await ws.call(Methods.TEAMS_TASK_ASSIGN, { teamId, taskId, agentId });
@@ -195,7 +203,7 @@ export function useTeams() {
return {
teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, getTeamScopes,
getTaskDetail, approveTask, rejectTask, addTaskComment, getTaskComments, getTaskEvents,
createTask, deleteTask, assignTask,
createTask, deleteTask, deleteTasksBulk, assignTask,
addMember, removeMember, updateTeamSettings, getKnownUsers,
};
}
@@ -1,7 +1,9 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback, useEffect } from "react";
import { ClipboardList, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team";
import type { TeamMemberData } from "@/types/team";
import { taskStatusBadgeVariant, isTerminalStatus } from "./task-utils";
@@ -20,17 +22,64 @@ interface TaskListProps {
events: TeamTaskEvent[]; attachments: TeamTaskAttachment[];
}>;
deleteTask?: (teamId: string, taskId: string) => Promise<void>;
deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise<number>;
}
export function TaskList({
tasks, loading, teamId, members, isTeamV2, emojiLookup,
getTaskDetail, deleteTask,
getTaskDetail, deleteTask, deleteTasksBulk,
}: TaskListProps) {
const { t } = useTranslation("teams");
const [selectedTask, setSelectedTask] = useState<TeamTaskData | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const taskLookup = useMemo(() => buildTaskLookup(tasks), [tasks]);
const memberLookup = useMemo(() => buildMemberLookup(members), [members]);
const terminalTaskIds = useMemo(
() => tasks.filter((t) => isTerminalStatus(t.status)).map((t) => t.id),
[tasks],
);
// Clear selection when tasks change (e.g. after delete/refresh).
useEffect(() => {
setSelectedIds((prev) => {
const taskIdSet = new Set(tasks.map((t) => t.id));
const next = new Set([...prev].filter((id) => taskIdSet.has(id)));
return next.size === prev.size ? prev : next;
});
}, [tasks]);
const toggleSelect = useCallback((taskId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(taskId)) next.delete(taskId);
else next.add(taskId);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) =>
prev.size === terminalTaskIds.length && terminalTaskIds.length > 0
? new Set()
: new Set(terminalTaskIds),
);
}, [terminalTaskIds]);
const handleBulkDelete = useCallback(async () => {
if (!deleteTasksBulk || selectedIds.size === 0) return;
setDeleting(true);
try {
await deleteTasksBulk(teamId, [...selectedIds]);
setSelectedIds(new Set());
setConfirmOpen(false);
} finally {
setDeleting(false);
}
}, [deleteTasksBulk, teamId, selectedIds]);
if (loading && tasks.length === 0) {
return <div className="py-8 text-center text-sm text-muted-foreground">{t("tasks.loading")}</div>;
}
@@ -52,10 +101,46 @@ export function TaskList({
deleteTask(teamId, taskId);
};
const hasBulkDelete = !!deleteTasksBulk && terminalTaskIds.length > 0;
const allSelected = terminalTaskIds.length > 0 && selectedIds.size === terminalTaskIds.length;
const someSelected = selectedIds.size > 0 && !allSelected;
const gridCols = hasBulkDelete
? "grid-cols-[36px_70px_1fr_90px_100px_60px_40px]"
: "grid-cols-[70px_1fr_90px_100px_60px_40px]";
return (
<>
{/* Bulk action bar */}
{hasBulkDelete && selectedIds.size > 0 && (
<div className="mb-2 flex items-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-2">
<span className="text-sm font-medium">
{t("tasks.selected", { count: selectedIds.size })}
</span>
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmOpen(true)}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{t("tasks.deleteSelected")}
</Button>
</div>
)}
<div className="overflow-x-auto rounded-lg border">
<div className="grid min-w-[500px] grid-cols-[70px_1fr_90px_100px_60px_40px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<div className={`grid min-w-[500px] ${gridCols} items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground`}>
{hasBulkDelete && (
<label className="flex items-center justify-center">
<input
type="checkbox"
className="h-4 w-4 cursor-pointer rounded border-muted-foreground/50 text-base accent-primary"
checked={allSelected}
ref={(el) => { if (el) el.indeterminate = someSelected; }}
onChange={toggleSelectAll}
/>
</label>
)}
<span>{t("tasks.columns.id")}</span>
<span>{t("tasks.columns.subject")}</span>
<span>{t("tasks.columns.status")}</span>
@@ -65,12 +150,28 @@ export function TaskList({
</div>
{tasks.map((task) => {
const ownerEmoji = task.owner_agent_id && emojiLookup?.get(task.owner_agent_id);
const isTerminal = isTerminalStatus(task.status);
const isChecked = selectedIds.has(task.id);
return (
<div
key={task.id}
className="grid min-w-[500px] cursor-pointer grid-cols-[70px_1fr_90px_100px_60px_40px] items-center gap-2 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
className={`grid min-w-[500px] cursor-pointer ${gridCols} items-center gap-2 border-b px-4 py-3 last:border-0 hover:bg-muted/30 ${isChecked ? "bg-destructive/5" : ""}`}
onClick={() => setSelectedTask(task)}
>
{hasBulkDelete && (
<label className="flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
{isTerminal ? (
<input
type="checkbox"
className="h-4 w-4 cursor-pointer rounded border-muted-foreground/50 text-base accent-primary"
checked={isChecked}
onChange={() => toggleSelect(task.id)}
/>
) : (
<span className="h-4 w-4" />
)}
</label>
)}
<span className="font-mono text-xs text-muted-foreground">{task.identifier || "\u2014"}</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{task.subject}</p>
@@ -80,7 +181,7 @@ export function TaskList({
{task.task_type && task.task_type !== "general" && (
<Badge variant="outline" className="mt-0.5 text-[10px]">{task.task_type}</Badge>
)}
{isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && !isTerminalStatus(task.status) && (
{isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && !isTerminal && (
<div className="mt-1 flex items-center gap-1.5">
<div className="h-1.5 flex-1 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${task.progress_percent}%` }} />
@@ -103,7 +204,7 @@ export function TaskList({
</span>
<span className="text-sm text-muted-foreground">{task.priority}</span>
<div>
{deleteTask && isTerminalStatus(task.status) && (
{deleteTask && isTerminal && (
<button
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => handleDelete(e, task.id)}
@@ -134,6 +235,17 @@ export function TaskList({
}}
/>
)}
<ConfirmDeleteDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t("tasks.deleteBulkTitle")}
description={t("tasks.deleteBulkConfirm", { count: selectedIds.size })}
confirmValue="delete"
confirmLabel={t("tasks.deleteSelected")}
onConfirm={handleBulkDelete}
loading={deleting}
/>
</>
);
}
+2 -1
View File
@@ -20,7 +20,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
const { t } = useTranslation("teams");
const {
getTeam, getTeamTasks, getTeamScopes, addMember, removeMember, deleteTeam,
getTaskDetail, deleteTask,
getTaskDetail, deleteTask, deleteTasksBulk,
} = useTeams();
const [team, setTeam] = useState<TeamData | null>(null);
@@ -100,6 +100,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
getTeamTasks={getTeamTasks}
getTaskDetail={getTaskDetail}
deleteTask={deleteTask}
deleteTasksBulk={deleteTasksBulk}
onWorkspace={() => setWorkspaceOpen(true)}
/>