diff --git a/internal/gateway/methods/teams_tasks.go b/internal/gateway/methods/teams_tasks.go index d50b7230..1d1e352f 100644 --- a/internal/gateway/methods/teams_tasks.go +++ b/internal/gateway/methods/teams_tasks.go @@ -52,6 +52,7 @@ func (m *TeamsMethods) RegisterTasks(router *gateway.MethodRouter) { router.Register(protocol.MethodTeamsTaskComments, m.handleTaskComments) router.Register(protocol.MethodTeamsTaskEvents, m.handleTaskEvents) router.Register(protocol.MethodTeamsTaskCreate, m.handleTaskCreate) + router.Register(protocol.MethodTeamsTaskDelete, m.handleTaskDelete) router.Register(protocol.MethodTeamsTaskAssign, m.handleTaskAssign) } @@ -583,6 +584,73 @@ func (m *TeamsMethods) handleTaskAssign(ctx context.Context, client *gateway.Cli } } +// --- Task Delete (hard-delete terminal-status tasks) --- + +type taskDeleteParams struct { + TeamID string `json:"teamId"` + TaskID string `json:"taskId"` +} + +func (m *TeamsMethods) handleTaskDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params taskDeleteParams + 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 + } + taskID, err := uuid.Parse(params.TaskID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId"))) + return + } + + // Validate task belongs to team (prevent IDOR). + task, err := m.teamStore.GetTask(ctx, taskID) + if err != nil { + if errors.Is(err, store.ErrTaskNotFound) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", ""))) + } else { + slog.Warn("teams.tasks.delete get failed", "task_id", taskID, "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, ""))) + } + return + } + if task.TeamID != teamID { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", ""))) + return + } + + if err := m.teamStore.DeleteTask(ctx, taskID, teamID); err != nil { + if errors.Is(err, store.ErrTaskNotFound) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "task is not in a deletable state")) + } else { + slog.Warn("teams.tasks.delete failed", "task_id", taskID, "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{"ok": true})) + + if m.msgBus != nil { + m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskDeleted, protocol.TeamTaskEventPayload{ + TeamID: teamID.String(), + TaskID: taskID.String(), + Status: task.Status, + 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 1851c8d6..523852ca 100644 --- a/internal/store/pg/teams_tasks.go +++ b/internal/store/pg/teams_tasks.go @@ -249,6 +249,20 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s return scanTaskRowsJoined(rows) } +func (s *PGTeamStore) DeleteTask(ctx context.Context, taskID, teamID uuid.UUID) error { + res, err := s.db.ExecContext(ctx, + `DELETE FROM team_tasks WHERE id = $1 AND team_id = $2 AND status IN ('completed','failed','cancelled')`, + taskID, teamID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return store.ErrTaskNotFound + } + return nil +} + 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 dba8d367..5bbb59dd 100644 --- a/internal/store/team_store.go +++ b/internal/store/team_store.go @@ -328,6 +328,9 @@ type TeamStore interface { // SearchTasks performs FTS search over task subject+description. // userID: if non-empty, filter to tasks created by this user. SearchTasks(ctx context.Context, teamID uuid.UUID, query string, limit int, userID string) ([]TeamTaskData, error) + // 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 // 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/events.go b/pkg/protocol/events.go index 355762fd..d47fd00a 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -51,6 +51,7 @@ const ( EventTeamTaskCommented = "team.task.commented" EventTeamTaskAssigned = "team.task.assigned" EventTeamTaskUpdated = "team.task.updated" + EventTeamTaskDeleted = "team.task.deleted" EventTeamTaskStale = "team.task.stale" // Team CRUD events (admin operations). diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 98f89f7e..3c35d326 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -114,6 +114,7 @@ const ( MethodTeamsTaskComments = "teams.tasks.comments" MethodTeamsTaskEvents = "teams.tasks.events" MethodTeamsTaskCreate = "teams.tasks.create" + MethodTeamsTaskDelete = "teams.tasks.delete" MethodTeamsTaskAssign = "teams.tasks.assign" MethodTeamsMembersAdd = "teams.members.add" MethodTeamsMembersRemove = "teams.members.remove" diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 679963dd..dd5f8a1a 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -139,6 +139,7 @@ export const Methods = { TEAMS_TASK_COMMENTS: "teams.tasks.comments", TEAMS_TASK_EVENTS: "teams.tasks.events", TEAMS_TASK_CREATE: "teams.tasks.create", + TEAMS_TASK_DELETE: "teams.tasks.delete", TEAMS_TASK_ASSIGN: "teams.tasks.assign", TEAMS_MEMBERS_ADD: "teams.members.add", TEAMS_MEMBERS_REMOVE: "teams.members.remove", @@ -199,6 +200,7 @@ export const Events = { TEAM_TASK_PROGRESS: "team.task.progress", TEAM_TASK_COMMENTED: "team.task.commented", TEAM_TASK_ASSIGNED: "team.task.assigned", + TEAM_TASK_DELETED: "team.task.deleted", // Team messages TEAM_MESSAGE_SENT: "team.message.sent", @@ -240,7 +242,7 @@ export const TEAM_RELATED_EVENTS: Set = new Set([ Events.TEAM_TASK_COMPLETED, Events.TEAM_TASK_CANCELLED, Events.TEAM_TASK_REVIEWED, Events.TEAM_TASK_APPROVED, Events.TEAM_TASK_REJECTED, Events.TEAM_TASK_PROGRESS, - Events.TEAM_TASK_COMMENTED, Events.TEAM_TASK_ASSIGNED, + Events.TEAM_TASK_COMMENTED, Events.TEAM_TASK_ASSIGNED, Events.TEAM_TASK_DELETED, Events.TEAM_MESSAGE_SENT, Events.TEAM_CREATED, Events.TEAM_UPDATED, Events.TEAM_DELETED, Events.TEAM_MEMBER_ADDED, Events.TEAM_MEMBER_REMOVED, diff --git a/ui/web/src/i18n/locales/en/teams.json b/ui/web/src/i18n/locales/en/teams.json index ae7078e9..aa54dd00 100644 --- a/ui/web/src/i18n/locales/en/teams.json +++ b/ui/web/src/i18n/locales/en/teams.json @@ -114,6 +114,8 @@ "followupNext": "Next reminder:", "followupDone": "All reminders sent" }, + "delete": "Delete", + "deleteConfirm": "Delete this task? This cannot be undone.", "badges": { "awaitingReply": "awaiting reply" } @@ -267,8 +269,10 @@ "failedAddMember": "Failed to add member", "failedRemoveMember": "Failed to remove member", "taskCreated": "Task created", + "taskDeleted": "Task deleted", "taskAssigned": "Task assigned", "failedCreateTask": "Failed to create task", + "failedDeleteTask": "Failed to delete task", "failedAssignTask": "Failed to assign task" }, "board": { diff --git a/ui/web/src/i18n/locales/vi/teams.json b/ui/web/src/i18n/locales/vi/teams.json index 276d5bb3..c28f5139 100644 --- a/ui/web/src/i18n/locales/vi/teams.json +++ b/ui/web/src/i18n/locales/vi/teams.json @@ -114,6 +114,8 @@ "followupNext": "Nhắc tiếp:", "followupDone": "Đã nhắc hết" }, + "delete": "Xoá", + "deleteConfirm": "Xoá task này? Không thể hoàn tác.", "badges": { "awaitingReply": "chờ trả lời" } @@ -267,8 +269,10 @@ "failedAddMember": "Không thể thêm thành viên", "failedRemoveMember": "Không thể xóa thành viên", "taskCreated": "Đã tạo nhiệm vụ", + "taskDeleted": "Đã xoá task", "taskAssigned": "Đã gán nhiệm vụ", "failedCreateTask": "Không thể tạo nhiệm vụ", + "failedDeleteTask": "Xoá task thất bại", "failedAssignTask": "Không thể gán nhiệm vụ" }, "board": { diff --git a/ui/web/src/i18n/locales/zh/teams.json b/ui/web/src/i18n/locales/zh/teams.json index 4c8f5d32..9bb0473f 100644 --- a/ui/web/src/i18n/locales/zh/teams.json +++ b/ui/web/src/i18n/locales/zh/teams.json @@ -114,6 +114,8 @@ "followupNext": "下次提醒:", "followupDone": "已全部提醒" }, + "delete": "删除", + "deleteConfirm": "删除此任务?此操作无法撤消。", "badges": { "awaitingReply": "等待回复" } @@ -267,8 +269,10 @@ "failedAddMember": "添加成员失败", "failedRemoveMember": "移除成员失败", "taskCreated": "任务已创建", + "taskDeleted": "任务已删除", "taskAssigned": "任务已分配", "failedCreateTask": "创建任务失败", + "failedDeleteTask": "删除任务失败", "failedAssignTask": "分配任务失败" }, "board": { diff --git a/ui/web/src/pages/teams/board/board-container.tsx b/ui/web/src/pages/teams/board/board-container.tsx index 2c53bc99..02197056 100644 --- a/ui/web/src/pages/teams/board/board-container.tsx +++ b/ui/web/src/pages/teams/board/board-container.tsx @@ -4,7 +4,7 @@ import { useWsEvent } from "@/hooks/use-ws-event"; import { Events } from "@/api/protocol"; import { useBoardStore } from "../stores/use-board-store"; import { toast } from "@/stores/use-toast-store"; -import { buildTaskLookup, buildMemberLookup } from "./board-utils"; +import { buildTaskLookup, buildMemberLookup, buildEmojiLookup } from "./board-utils"; import { BoardToolbar } from "./board-toolbar"; import { KanbanBoard } from "./kanban-board"; import { TaskDetailDialog } from "../task-sections/task-detail-dialog"; @@ -23,11 +23,12 @@ interface BoardContainerProps { isTeamV2: boolean; 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; } export const BoardContainer = memo(function BoardContainer({ teamId, members, scopes, isTeamV2, - getTeamTasks, getTaskDetail, + getTeamTasks, getTaskDetail, deleteTask, }: BoardContainerProps) { const { t } = useTranslation("teams"); const viewMode = useBoardStore((s) => s.viewMode); @@ -43,6 +44,7 @@ export const BoardContainer = memo(function BoardContainer({ // Lookups for name resolution const taskLookup = useMemo(() => buildTaskLookup(tasks), [tasks]); const memberLookup = useMemo(() => buildMemberLookup(members), [members]); + const emojiLookup = useMemo(() => buildEmojiLookup(members), [members]); // Stable refs for filter values — avoids recreating load callback const filtersRef = useRef({ statusFilter, selectedScope }); @@ -98,6 +100,7 @@ export const BoardContainer = memo(function BoardContainer({ useWsEvent(Events.TEAM_TASK_PROGRESS, reloadOnEvent); useWsEvent(Events.TEAM_TASK_COMMENTED, reloadOnEvent); useWsEvent(Events.TEAM_TASK_ASSIGNED, reloadOnEvent); + useWsEvent(Events.TEAM_TASK_DELETED, reloadOnEvent); // Stable callbacks for children — never change identity const handleRefresh = useCallback(() => load(true), [load]); @@ -105,6 +108,20 @@ export const BoardContainer = memo(function BoardContainer({ const handleTaskClick = useCallback((task: TeamTaskData) => setSelectedTask(task), []); const handleCloseDetail = useCallback(() => setSelectedTask(null), []); + // Delete handler for kanban cards (confirm + call API) + const deleteTaskRef = useRef(deleteTask); + deleteTaskRef.current = deleteTask; + const handleDeleteTask = useCallback(async (taskId: string) => { + if (!deleteTaskRef.current) return; + if (!window.confirm(t("tasks.deleteConfirm"))) return; + try { + await deleteTaskRef.current(teamId, taskId); + toast.success(t("toast.taskDeleted")); + } catch { + toast.error(t("toast.failedDeleteTask")); + } + }, [teamId, t]); + return (
) : ( )}
@@ -147,8 +168,10 @@ export const BoardContainer = memo(function BoardContainer({ isTeamV2={isTeamV2} onClose={handleCloseDetail} getTaskDetail={getTaskDetail} + deleteTask={deleteTask} taskLookup={taskLookup} memberLookup={memberLookup} + emojiLookup={emojiLookup} /> )} diff --git a/ui/web/src/pages/teams/board/board-utils.ts b/ui/web/src/pages/teams/board/board-utils.ts index 9359bb88..c5e2d733 100644 --- a/ui/web/src/pages/teams/board/board-utils.ts +++ b/ui/web/src/pages/teams/board/board-utils.ts @@ -65,6 +65,17 @@ export function buildMemberLookup( return map; } +/** Build agent_id -> emoji lookup from members */ +export function buildEmojiLookup( + members: TeamMemberData[], +): Map { + const map = new Map(); + for (const m of members) { + if (m.emoji) map.set(m.agent_id, m.emoji); + } + return map; +} + /** Check if agent is actively running on a task */ export function isTaskLocked(task: TeamTaskData): boolean { if (!task.locked_at) return false; diff --git a/ui/web/src/pages/teams/board/kanban-board.tsx b/ui/web/src/pages/teams/board/kanban-board.tsx index 2fea1622..55f3a699 100644 --- a/ui/web/src/pages/teams/board/kanban-board.tsx +++ b/ui/web/src/pages/teams/board/kanban-board.tsx @@ -8,10 +8,12 @@ interface KanbanBoardProps { tasks: TeamTaskData[]; isTeamV2?: boolean; groupBy: GroupBy; + emojiLookup?: Map; onTaskClick: (task: TeamTaskData) => void; + onDeleteTask?: (taskId: string) => void; } -export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, onTaskClick }: KanbanBoardProps) { +export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, emojiLookup, onTaskClick, onDeleteTask }: KanbanBoardProps) { const grouped = useMemo(() => groupTasksBy(tasks, groupBy), [tasks, groupBy]); const columns = useMemo(() => { @@ -36,7 +38,9 @@ export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, title={col} tasks={grouped.get(col) ?? []} isTeamV2={isTeamV2} + emojiLookup={emojiLookup} onTaskClick={onTaskClick} + onDeleteTask={onDeleteTask} /> ))} diff --git a/ui/web/src/pages/teams/board/kanban-card.tsx b/ui/web/src/pages/teams/board/kanban-card.tsx index 9f4d8f17..cd01ecac 100644 --- a/ui/web/src/pages/teams/board/kanban-card.tsx +++ b/ui/web/src/pages/teams/board/kanban-card.tsx @@ -1,35 +1,50 @@ import { memo } from "react"; +import { Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useTranslation } from "react-i18next"; import { isTaskLocked } from "./board-utils"; +import { isTerminalStatus } from "../task-sections/task-utils"; import type { TeamTaskData } from "@/types/team"; -const PRIORITY_COLORS: Record = { - 0: "bg-slate-400", - 1: "bg-blue-500", - 2: "bg-amber-500", - 3: "bg-red-500", +const PRIORITY_LABELS: Record = { + 0: { label: "P-0", color: "text-slate-400" }, + 1: { label: "P-1", color: "text-blue-500" }, + 2: { label: "P-2", color: "text-amber-500" }, + 3: { label: "P-3", color: "text-red-500" }, +}; + +const PRIORITY_TOOLTIPS: Record = { + 0: "Low", + 1: "Medium", + 2: "High", + 3: "Critical", }; interface KanbanCardProps { task: TeamTaskData; isTeamV2?: boolean; + emojiLookup?: Map; onClick: () => void; + onDelete?: (taskId: string) => void; } -export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, onClick }: KanbanCardProps) { +export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup, onClick, onDelete }: KanbanCardProps) { const { t } = useTranslation("teams"); const locked = isTaskLocked(task); const blocked = task.status === "blocked"; + const ownerEmoji = task.owner_agent_id && emojiLookup?.get(task.owner_agent_id); + const canDelete = onDelete && isTerminalStatus(task.status); + const prio = PRIORITY_LABELS[task.priority] ?? PRIORITY_LABELS[0]!; return (
+ {/* Top row: identifier + running badge + delete button */}
{task.identifier || `#${task.task_number ?? ""}`} @@ -41,22 +56,35 @@ export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, onClick }: {t("board.running")} )} - + {canDelete && ( + + )}

{task.subject}

+ {/* Bottom row: owner + type badge + priority */}
+ {ownerEmoji && {ownerEmoji}} {task.owner_agent_key || t("board.unassigned")} {task.task_type && task.task_type !== "general" && ( {task.task_type} )} + + {prio.label} +
{isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && ( diff --git a/ui/web/src/pages/teams/board/kanban-column.tsx b/ui/web/src/pages/teams/board/kanban-column.tsx index 1ebd289e..c9b63df6 100644 --- a/ui/web/src/pages/teams/board/kanban-column.tsx +++ b/ui/web/src/pages/teams/board/kanban-column.tsx @@ -10,10 +10,12 @@ interface KanbanColumnProps { title: string; tasks: TeamTaskData[]; isTeamV2?: boolean; + emojiLookup?: Map; onTaskClick: (task: TeamTaskData) => void; + onDeleteTask?: (taskId: string) => void; } -export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, isTeamV2, onTaskClick }: KanbanColumnProps) { +export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, isTeamV2, emojiLookup, onTaskClick, onDeleteTask }: KanbanColumnProps) { const { t } = useTranslation("teams"); return ( @@ -33,7 +35,9 @@ export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, key={task.id} task={task} isTeamV2={isTeamV2} + emojiLookup={emojiLookup} onClick={() => onTaskClick(task)} + onDelete={onDeleteTask} /> )) )} diff --git a/ui/web/src/pages/teams/hooks/use-teams.ts b/ui/web/src/pages/teams/hooks/use-teams.ts index 8d6027ac..7127d13b 100644 --- a/ui/web/src/pages/teams/hooks/use-teams.ts +++ b/ui/web/src/pages/teams/hooks/use-teams.ts @@ -146,6 +146,13 @@ export function useTeams() { [ws], ); + const deleteTask = useCallback( + async (teamId: string, taskId: string) => { + await ws.call(Methods.TEAMS_TASK_DELETE, { teamId, taskId }); + }, + [ws], + ); + const assignTask = useCallback( async (teamId: string, taskId: string, agentId: string) => { await ws.call(Methods.TEAMS_TASK_ASSIGN, { teamId, taskId, agentId }); @@ -188,7 +195,7 @@ export function useTeams() { return { teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, getTeamScopes, getTaskDetail, approveTask, rejectTask, addTaskComment, getTaskComments, getTaskEvents, - createTask, assignTask, + createTask, deleteTask, assignTask, addMember, removeMember, updateTeamSettings, getKnownUsers, }; } diff --git a/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx b/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx index 2f2a9f42..6b6f285f 100644 --- a/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx +++ b/ui/web/src/pages/teams/task-sections/task-detail-dialog.tsx @@ -3,10 +3,13 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { formatDate } from "@/lib/format"; +import { toast } from "@/stores/use-toast-store"; import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team"; -import { taskStatusBadgeVariant } from "./task-utils"; +import { taskStatusBadgeVariant, isTerminalStatus } from "./task-utils"; interface TaskDetailDialogProps { task: TeamTaskData; @@ -17,13 +20,15 @@ interface TaskDetailDialogProps { task: TeamTaskData; comments: TeamTaskComment[]; events: TeamTaskEvent[]; attachments: TeamTaskAttachment[]; }>; + deleteTask?: (teamId: string, taskId: string) => Promise; taskLookup?: Map; memberLookup?: Map; + emojiLookup?: Map; } export function TaskDetailDialog({ task, teamId, isTeamV2, onClose, - getTaskDetail, taskLookup, memberLookup, + getTaskDetail, deleteTask, taskLookup, memberLookup, emojiLookup, }: TaskDetailDialogProps) { const { t } = useTranslation("teams"); const [events, setEvents] = useState([]); @@ -42,16 +47,41 @@ export function TaskDetailDialog({ const resolveMember = (id?: string) => (id && memberLookup?.get(id)) || undefined; + const resolveEmoji = (id?: string) => + (id && emojiLookup?.get(id)) || undefined; + + const handleDelete = async () => { + if (!deleteTask) return; + if (!window.confirm(t("tasks.deleteConfirm"))) return; + try { + await deleteTask(teamId, task.id); + toast.success(t("toast.taskDeleted")); + onClose(); + } catch { + toast.error(t("toast.failedDeleteTask")); + } + }; + + const ownerEmoji = resolveEmoji(task.owner_agent_id); + const canDelete = deleteTask && isTerminalStatus(task.status); + return ( onClose()}> - - {task.identifier && ( - {task.identifier} +
+ + {task.identifier && ( + {task.identifier} + )} + {t("tasks.detail.title")} + + {canDelete && ( + )} - {t("tasks.detail.title")} - +
@@ -116,7 +146,10 @@ export function TaskDetailDialog({
{t("tasks.detail.owner")}{" "} - {resolveMember(task.owner_agent_id) || task.owner_agent_key || "—"} + + {ownerEmoji && {ownerEmoji}} + {resolveMember(task.owner_agent_id) || task.owner_agent_key || "\u2014"} +
{task.task_type && task.task_type !== "general" && (
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 4e145a0f..186a82ac 100644 --- a/ui/web/src/pages/teams/task-sections/task-list.tsx +++ b/ui/web/src/pages/teams/task-sections/task-list.tsx @@ -1,10 +1,10 @@ import { useState, useMemo } from "react"; -import { ClipboardList } from "lucide-react"; +import { ClipboardList, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useTranslation } from "react-i18next"; import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team"; import type { TeamMemberData } from "@/types/team"; -import { taskStatusBadgeVariant } from "./task-utils"; +import { taskStatusBadgeVariant, isTerminalStatus } from "./task-utils"; import { TaskDetailDialog } from "./task-detail-dialog"; import { buildTaskLookup, buildMemberLookup } from "../board/board-utils"; @@ -14,15 +14,17 @@ interface TaskListProps { teamId: string; members: TeamMemberData[]; isTeamV2?: boolean; + emojiLookup?: Map; getTaskDetail: (teamId: string, taskId: string) => Promise<{ task: TeamTaskData; comments: TeamTaskComment[]; events: TeamTaskEvent[]; attachments: TeamTaskAttachment[]; }>; + deleteTask?: (teamId: string, taskId: string) => Promise; } export function TaskList({ - tasks, loading, teamId, members, isTeamV2, - getTaskDetail, + tasks, loading, teamId, members, isTeamV2, emojiLookup, + getTaskDetail, deleteTask, }: TaskListProps) { const { t } = useTranslation("teams"); const [selectedTask, setSelectedTask] = useState(null); @@ -43,44 +45,68 @@ export function TaskList({ ); } + const handleDelete = (e: React.MouseEvent, taskId: string) => { + e.stopPropagation(); + if (!deleteTask) return; + if (!window.confirm(t("tasks.deleteConfirm"))) return; + deleteTask(teamId, taskId); + }; + return ( <>
-
+
{t("tasks.columns.id")} {t("tasks.columns.subject")} {t("tasks.columns.status")} {t("tasks.columns.owner")} {t("tasks.columns.priority")} +
- {tasks.map((task) => ( -
setSelectedTask(task)} - > - {task.identifier || "—"} -
-

{task.subject}

- {task.description && ( -

{task.description}

- )} - {task.task_type && task.task_type !== "general" && ( - {task.task_type} - )} + {tasks.map((task) => { + const ownerEmoji = task.owner_agent_id && emojiLookup?.get(task.owner_agent_id); + return ( +
setSelectedTask(task)} + > + {task.identifier || "\u2014"} +
+

{task.subject}

+ {task.description && ( +

{task.description}

+ )} + {task.task_type && task.task_type !== "general" && ( + {task.task_type} + )} +
+
+ {task.status.replace(/_/g, " ")} + {isTeamV2 && task.followup_at && task.status === "in_progress" && ( + + {t("tasks.badges.awaitingReply")} + + )} +
+ + {ownerEmoji && {ownerEmoji}} + {task.owner_agent_key || "\u2014"} + + {task.priority} +
+ {deleteTask && isTerminalStatus(task.status) && ( + + )} +
-
- {task.status.replace(/_/g, " ")} - {isTeamV2 && task.followup_at && task.status === "in_progress" && ( - - {t("tasks.badges.awaitingReply")} - - )} -
- {task.owner_agent_key || "—"} - {task.priority} -
- ))} + ); + })}
{selectedTask && ( @@ -90,8 +116,10 @@ export function TaskList({ isTeamV2={isTeamV2} onClose={() => setSelectedTask(null)} getTaskDetail={getTaskDetail} + deleteTask={deleteTask} taskLookup={taskLookup} memberLookup={memberLookup} + emojiLookup={emojiLookup} /> )} diff --git a/ui/web/src/pages/teams/task-sections/task-utils.ts b/ui/web/src/pages/teams/task-sections/task-utils.ts index 1d607d90..4c12f39f 100644 --- a/ui/web/src/pages/teams/task-sections/task-utils.ts +++ b/ui/web/src/pages/teams/task-sections/task-utils.ts @@ -16,3 +16,10 @@ export function taskStatusBadgeVariant(status: string) { export function isTaskActionable(status: string) { return status !== "completed" && status !== "failed"; } + +const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); + +/** Whether the task is in a terminal status and can be deleted */ +export function isTerminalStatus(status: string) { + return TERMINAL_STATUSES.has(status); +} diff --git a/ui/web/src/pages/teams/team-detail-page.tsx b/ui/web/src/pages/teams/team-detail-page.tsx index a4fc604d..e6a20a14 100644 --- a/ui/web/src/pages/teams/team-detail-page.tsx +++ b/ui/web/src/pages/teams/team-detail-page.tsx @@ -20,7 +20,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { const { t } = useTranslation("teams"); const { getTeam, getTeamTasks, getTeamScopes, addMember, removeMember, deleteTeam, - getTaskDetail, + getTaskDetail, deleteTask, } = useTeams(); const [team, setTeam] = useState(null); @@ -100,6 +100,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { isTeamV2={isTeamV2} getTeamTasks={getTeamTasks} getTaskDetail={getTaskDetail} + deleteTask={deleteTask} /> {/* Team info + settings + members dialog */}