diff --git a/internal/gateway/methods/teams_tasks.go b/internal/gateway/methods/teams_tasks.go index 30073cee..86e62853 100644 --- a/internal/gateway/methods/teams_tasks.go +++ b/internal/gateway/methods/teams_tasks.go @@ -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, ¶ms) + 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. diff --git a/internal/store/pg/teams_tasks.go b/internal/store/pg/teams_tasks.go index 0854654a..1af1ec87 100644 --- a/internal/store/pg/teams_tasks.go +++ b/internal/store/pg/teams_tasks.go @@ -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() { diff --git a/internal/store/team_store.go b/internal/store/team_store.go index 9bc83ddf..98944a18 100644 --- a/internal/store/team_store.go +++ b/internal/store/team_store.go @@ -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). diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 3c35d326..cefaa168 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -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" diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index a5629ee2..1e5fb107 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -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", diff --git a/ui/web/src/i18n/locales/en/teams.json b/ui/web/src/i18n/locales/en/teams.json index f0604cb4..ecf328f3 100644 --- a/ui/web/src/i18n/locales/en/teams.json +++ b/ui/web/src/i18n/locales/en/teams.json @@ -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" } diff --git a/ui/web/src/i18n/locales/vi/teams.json b/ui/web/src/i18n/locales/vi/teams.json index cdb798f4..9aa61690 100644 --- a/ui/web/src/i18n/locales/vi/teams.json +++ b/ui/web/src/i18n/locales/vi/teams.json @@ -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" } diff --git a/ui/web/src/i18n/locales/zh/teams.json b/ui/web/src/i18n/locales/zh/teams.json index 99efb1c8..7a741c6b 100644 --- a/ui/web/src/i18n/locales/zh/teams.json +++ b/ui/web/src/i18n/locales/zh/teams.json @@ -120,6 +120,10 @@ }, "delete": "删除", "deleteConfirm": "删除此任务?此操作无法撤消。", + "selected": "已选择 {{count}} 项", + "deleteSelected": "删除所选", + "deleteBulkTitle": "批量删除任务", + "deleteBulkConfirm": "此操作将永久删除 {{count}} 个任务。输入「delete」确认。", "badges": { "awaitingReply": "等待回复" } diff --git a/ui/web/src/pages/teams/board/board-container.tsx b/ui/web/src/pages/teams/board/board-container.tsx index bb4cd680..56c3d640 100644 --- a/ui/web/src/pages/teams/board/board-container.tsx +++ b/ui/web/src/pages/teams/board/board-container.tsx @@ -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; + deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise; 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} /> )} diff --git a/ui/web/src/pages/teams/hooks/use-teams.ts b/ui/web/src/pages/teams/hooks/use-teams.ts index 7127d13b..e3ba8934 100644 --- a/ui/web/src/pages/teams/hooks/use-teams.ts +++ b/ui/web/src/pages/teams/hooks/use-teams.ts @@ -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, }; } diff --git a/ui/web/src/pages/teams/task-sections/task-list.tsx b/ui/web/src/pages/teams/task-sections/task-list.tsx index f7bf81c3..134d2c72 100644 --- a/ui/web/src/pages/teams/task-sections/task-list.tsx +++ b/ui/web/src/pages/teams/task-sections/task-list.tsx @@ -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; + deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise; } export function TaskList({ tasks, loading, teamId, members, isTeamV2, emojiLookup, - getTaskDetail, deleteTask, + getTaskDetail, deleteTask, deleteTasksBulk, }: TaskListProps) { const { t } = useTranslation("teams"); const [selectedTask, setSelectedTask] = useState(null); + const [selectedIds, setSelectedIds] = useState>(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
{t("tasks.loading")}
; } @@ -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 && ( +
+ + {t("tasks.selected", { count: selectedIds.size })} + + +
+ )} +
-
+
+ {hasBulkDelete && ( + + )} {t("tasks.columns.id")} {t("tasks.columns.subject")} {t("tasks.columns.status")} @@ -65,12 +150,28 @@ export function TaskList({
{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 (
setSelectedTask(task)} > + {hasBulkDelete && ( + + )} {task.identifier || "\u2014"}

{task.subject}

@@ -80,7 +181,7 @@ export function TaskList({ {task.task_type && task.task_type !== "general" && ( {task.task_type} )} - {isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && !isTerminalStatus(task.status) && ( + {isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && !isTerminal && (
@@ -103,7 +204,7 @@ export function TaskList({ {task.priority}
- {deleteTask && isTerminalStatus(task.status) && ( + {deleteTask && isTerminal && (