feat(teams): add task delete + agent emoji + priority labels in board views

- Backend: DeleteTask RPC handler with terminal-status guard (completed/failed/cancelled)
- Frontend: delete button in kanban cards (top-right, hover), task list, and detail dialog
- Show agent emoji from members lookup next to owner name in all task views
- Replace priority colored dot with P-0/P-1/P-2/P-3 labels with hover tooltips
- Auto-reload board on TEAM_TASK_DELETED WebSocket event
- i18n: add delete task strings for en/vi/zh
This commit is contained in:
viettranx committed 2026-03-15 23:47:53 +07:00
1 parent e138ac7676
commit 950d5d5c94
19 files changed
+305 -58

No files matched your search

+68
View File
@@ -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, &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
}
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.
+14
View File
@@ -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() {
+3
View File
@@ -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).
+1
View File
@@ -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).
+1
View File
@@ -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"
+3 -1
View File
@@ -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<string> = 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,
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -114,6 +114,8 @@
"followupNext": "下次提醒:",
"followupDone": "已全部提醒"
},
"delete": "删除",
"deleteConfirm": "删除此任务?此操作无法撤消。",
"badges": {
"awaitingReply": "等待回复"
}
@@ -267,8 +269,10 @@
"failedAddMember": "添加成员失败",
"failedRemoveMember": "移除成员失败",
"taskCreated": "任务已创建",
"taskDeleted": "任务已删除",
"taskAssigned": "任务已分配",
"failedCreateTask": "创建任务失败",
"failedDeleteTask": "删除任务失败",
"failedAssignTask": "分配任务失败"
},
"board": {
@@ -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<void>;
}
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 (
<div className="flex flex-1 flex-col gap-3 overflow-hidden p-3 sm:p-4">
<BoardToolbar
@@ -126,7 +143,9 @@ export const BoardContainer = memo(function BoardContainer({
tasks={tasks}
isTeamV2={isTeamV2}
groupBy={groupBy}
emojiLookup={emojiLookup}
onTaskClick={handleTaskClick}
onDeleteTask={deleteTask ? handleDeleteTask : undefined}
/>
) : (
<TaskList
@@ -135,7 +154,9 @@ export const BoardContainer = memo(function BoardContainer({
teamId={teamId}
members={members}
isTeamV2={isTeamV2}
emojiLookup={emojiLookup}
getTaskDetail={getTaskDetail}
deleteTask={deleteTask}
/>
)}
</div>
@@ -147,8 +168,10 @@ export const BoardContainer = memo(function BoardContainer({
isTeamV2={isTeamV2}
onClose={handleCloseDetail}
getTaskDetail={getTaskDetail}
deleteTask={deleteTask}
taskLookup={taskLookup}
memberLookup={memberLookup}
emojiLookup={emojiLookup}
/>
)}
</div>
@@ -65,6 +65,17 @@ export function buildMemberLookup(
return map;
}
/** Build agent_id -> emoji lookup from members */
export function buildEmojiLookup(
members: TeamMemberData[],
): Map<string, string> {
const map = new Map<string, string>();
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;
@@ -8,10 +8,12 @@ interface KanbanBoardProps {
tasks: TeamTaskData[];
isTeamV2?: boolean;
groupBy: GroupBy;
emojiLookup?: Map<string, string>;
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}
/>
</div>
))}
+39 -11
View File
@@ -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<number, string> = {
0: "bg-slate-400",
1: "bg-blue-500",
2: "bg-amber-500",
3: "bg-red-500",
const PRIORITY_LABELS: Record<number, { label: string; color: string }> = {
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<number, string> = {
0: "Low",
1: "Medium",
2: "High",
3: "Critical",
};
interface KanbanCardProps {
task: TeamTaskData;
isTeamV2?: boolean;
emojiLookup?: Map<string, string>;
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 (
<div
className={
"cursor-pointer rounded-lg border bg-card p-3 shadow-sm transition-colors hover:bg-accent/50" +
"group relative cursor-pointer rounded-lg border bg-card p-3 shadow-sm transition-colors hover:bg-accent/50" +
(locked ? " border-l-2 border-l-green-500" : blocked ? " border-l-2 border-l-amber-500" : "")
}
onClick={onClick}
>
{/* Top row: identifier + running badge + delete button */}
<div className="mb-1 flex items-center justify-between">
<span className="font-mono text-[10px] text-muted-foreground">
{task.identifier || `#${task.task_number ?? ""}`}
@@ -41,22 +56,35 @@ export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, onClick }:
{t("board.running")}
</span>
)}
<span
className={`h-2 w-2 rounded-full ${PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS[0]}`}
title={`Priority ${task.priority}`}
/>
{canDelete && (
<button
className="hidden rounded p-0.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive group-hover:inline-flex"
title={t("tasks.delete")}
onClick={(e) => { e.stopPropagation(); onDelete(task.id); }}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
</div>
<p className="line-clamp-2 text-sm font-medium leading-snug">{task.subject}</p>
{/* Bottom row: owner + type badge + priority */}
<div className="mt-2 flex items-center gap-1.5">
{ownerEmoji && <span className="text-sm leading-none">{ownerEmoji}</span>}
<span className="truncate text-xs text-muted-foreground">
{task.owner_agent_key || t("board.unassigned")}
</span>
{task.task_type && task.task_type !== "general" && (
<Badge variant="outline" className="text-[10px] px-1 py-0">{task.task_type}</Badge>
)}
<span
className={`ml-auto shrink-0 font-mono text-[10px] font-medium ${prio.color}`}
title={`${PRIORITY_TOOLTIPS[task.priority] ?? "Low"} priority`}
>
{prio.label}
</span>
</div>
{isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && (
@@ -10,10 +10,12 @@ interface KanbanColumnProps {
title: string;
tasks: TeamTaskData[];
isTeamV2?: boolean;
emojiLookup?: Map<string, string>;
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}
/>
))
)}
+8 -1
View File
@@ -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,
};
}
@@ -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<void>;
taskLookup?: Map<string, string>;
memberLookup?: Map<string, string>;
emojiLookup?: Map<string, string>;
}
export function TaskDetailDialog({
task, teamId, isTeamV2, onClose,
getTaskDetail, taskLookup, memberLookup,
getTaskDetail, deleteTask, taskLookup, memberLookup, emojiLookup,
}: TaskDetailDialogProps) {
const { t } = useTranslation("teams");
const [events, setEvents] = useState<TeamTaskEvent[]>([]);
@@ -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 (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-h-[85vh] w-[95vw] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{task.identifier && (
<Badge variant="outline" className="font-mono text-xs">{task.identifier}</Badge>
<div className="flex items-center justify-between gap-2">
<DialogTitle className="flex items-center gap-2">
{task.identifier && (
<Badge variant="outline" className="font-mono text-xs">{task.identifier}</Badge>
)}
{t("tasks.detail.title")}
</DialogTitle>
{canDelete && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:bg-destructive/10" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
)}
{t("tasks.detail.title")}
</DialogTitle>
</div>
</DialogHeader>
<div className="space-y-4">
@@ -116,7 +146,10 @@ export function TaskDetailDialog({
</div>
<div>
<span className="text-muted-foreground">{t("tasks.detail.owner")}</span>{" "}
<span className="font-medium">{resolveMember(task.owner_agent_id) || task.owner_agent_key || "—"}</span>
<span className="font-medium">
{ownerEmoji && <span className="mr-1">{ownerEmoji}</span>}
{resolveMember(task.owner_agent_id) || task.owner_agent_key || "\u2014"}
</span>
</div>
{task.task_type && task.task_type !== "general" && (
<div>
@@ -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<string, string>;
getTaskDetail: (teamId: string, taskId: string) => Promise<{
task: TeamTaskData; comments: TeamTaskComment[];
events: TeamTaskEvent[]; attachments: TeamTaskAttachment[];
}>;
deleteTask?: (teamId: string, taskId: string) => Promise<void>;
}
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<TeamTaskData | null>(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 (
<>
<div className="overflow-x-auto rounded-lg border">
<div className="grid min-w-[500px] grid-cols-[70px_1fr_90px_100px_60px] 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] 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">
<span>{t("tasks.columns.id")}</span>
<span>{t("tasks.columns.subject")}</span>
<span>{t("tasks.columns.status")}</span>
<span>{t("tasks.columns.owner")}</span>
<span>{t("tasks.columns.priority")}</span>
<span />
</div>
{tasks.map((task) => (
<div
key={task.id}
className="grid min-w-[500px] cursor-pointer grid-cols-[70px_1fr_90px_100px_60px] items-center gap-2 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
onClick={() => setSelectedTask(task)}
>
<span className="font-mono text-xs text-muted-foreground">{task.identifier || "—"}</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{task.subject}</p>
{task.description && (
<p className="truncate text-xs text-muted-foreground/70">{task.description}</p>
)}
{task.task_type && task.task_type !== "general" && (
<Badge variant="outline" className="mt-0.5 text-[10px]">{task.task_type}</Badge>
)}
{tasks.map((task) => {
const ownerEmoji = task.owner_agent_id && emojiLookup?.get(task.owner_agent_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"
onClick={() => setSelectedTask(task)}
>
<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>
{task.description && (
<p className="truncate text-xs text-muted-foreground/70">{task.description}</p>
)}
{task.task_type && task.task_type !== "general" && (
<Badge variant="outline" className="mt-0.5 text-[10px]">{task.task_type}</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-1">
<Badge variant={taskStatusBadgeVariant(task.status)}>{task.status.replace(/_/g, " ")}</Badge>
{isTeamV2 && task.followup_at && task.status === "in_progress" && (
<Badge variant="outline" className="border-amber-500/50 bg-amber-500/10 text-[10px] text-amber-700 dark:text-amber-400">
{t("tasks.badges.awaitingReply")}
</Badge>
)}
</div>
<span className="flex items-center gap-1 truncate text-sm text-muted-foreground">
{ownerEmoji && <span className="text-sm leading-none">{ownerEmoji}</span>}
{task.owner_agent_key || "\u2014"}
</span>
<span className="text-sm text-muted-foreground">{task.priority}</span>
<div>
{deleteTask && isTerminalStatus(task.status) && (
<button
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => handleDelete(e, task.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-1">
<Badge variant={taskStatusBadgeVariant(task.status)}>{task.status.replace(/_/g, " ")}</Badge>
{isTeamV2 && task.followup_at && task.status === "in_progress" && (
<Badge variant="outline" className="border-amber-500/50 bg-amber-500/10 text-[10px] text-amber-700 dark:text-amber-400">
{t("tasks.badges.awaitingReply")}
</Badge>
)}
</div>
<span className="truncate text-sm text-muted-foreground">{task.owner_agent_key || "—"}</span>
<span className="text-sm text-muted-foreground">{task.priority}</span>
</div>
))}
);
})}
</div>
{selectedTask && (
@@ -90,8 +116,10 @@ export function TaskList({
isTeamV2={isTeamV2}
onClose={() => setSelectedTask(null)}
getTaskDetail={getTaskDetail}
deleteTask={deleteTask}
taskLookup={taskLookup}
memberLookup={memberLookup}
emojiLookup={emojiLookup}
/>
)}
</>
@@ -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);
}
+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,
getTaskDetail, deleteTask,
} = useTeams();
const [team, setTeam] = useState<TeamData | null>(null);
@@ -100,6 +100,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
isTeamV2={isTeamV2}
getTeamTasks={getTeamTasks}
getTaskDetail={getTaskDetail}
deleteTask={deleteTask}
/>
{/* Team info + settings + members dialog */}