mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 14:13:34 +00:00
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:
1 parent
e138ac7676
commit
950d5d5c94
19 files changed
+305
-58
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in new issue
Block a user