From 89303f5624b0834dfd17aacb73d5b58986416244 Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 16 Mar 2026 20:06:35 +0700 Subject: [PATCH] feat(ui): improve team board with task snapshots and board toolbar --- .../src/pages/teams/board/board-container.tsx | 6 ++++ ui/web/src/pages/teams/board/board-utils.ts | 2 +- ui/web/src/pages/teams/board/kanban-board.tsx | 4 ++- ui/web/src/pages/teams/board/kanban-card.tsx | 25 +++++++++++++--- .../src/pages/teams/board/kanban-column.tsx | 29 ++++++++++++------- .../task-sections/task-detail-dialog.tsx | 12 ++++++-- .../pages/teams/task-sections/task-list.tsx | 4 +++ 7 files changed, 63 insertions(+), 19 deletions(-) diff --git a/ui/web/src/pages/teams/board/board-container.tsx b/ui/web/src/pages/teams/board/board-container.tsx index 6330ea0d..bb4cd680 100644 --- a/ui/web/src/pages/teams/board/board-container.tsx +++ b/ui/web/src/pages/teams/board/board-container.tsx @@ -108,6 +108,10 @@ export const BoardContainer = memo(function BoardContainer({ const handleCreateTask = useCallback(() => toast.info(t("board.createViaChat")), [t]); const handleTaskClick = useCallback((task: TeamTaskData) => setSelectedTask(task), []); const handleCloseDetail = useCallback(() => setSelectedTask(null), []); + const handleNavigateTask = useCallback((taskId: string) => { + const found = tasks.find((t) => t.id === taskId); + if (found) setSelectedTask(found); + }, [tasks]); // Delete handler for kanban cards (confirm + call API) const deleteTaskRef = useRef(deleteTask); @@ -146,6 +150,7 @@ export const BoardContainer = memo(function BoardContainer({ isTeamV2={isTeamV2} groupBy={groupBy} emojiLookup={emojiLookup} + taskLookup={taskLookup} onTaskClick={handleTaskClick} onDeleteTask={deleteTask ? handleDeleteTask : undefined} /> @@ -174,6 +179,7 @@ export const BoardContainer = memo(function BoardContainer({ taskLookup={taskLookup} memberLookup={memberLookup} emojiLookup={emojiLookup} + onNavigateTask={handleNavigateTask} /> )} diff --git a/ui/web/src/pages/teams/board/board-utils.ts b/ui/web/src/pages/teams/board/board-utils.ts index c5e2d733..cdac4303 100644 --- a/ui/web/src/pages/teams/board/board-utils.ts +++ b/ui/web/src/pages/teams/board/board-utils.ts @@ -3,9 +3,9 @@ import type { TeamTaskData, TeamMemberData } from "@/types/team"; /** All kanban column statuses in display order */ export const KANBAN_STATUSES = [ "pending", + "blocked", "in_progress", "completed", - "blocked", "failed", "cancelled", ] as const; diff --git a/ui/web/src/pages/teams/board/kanban-board.tsx b/ui/web/src/pages/teams/board/kanban-board.tsx index 55f3a699..99576d02 100644 --- a/ui/web/src/pages/teams/board/kanban-board.tsx +++ b/ui/web/src/pages/teams/board/kanban-board.tsx @@ -9,11 +9,12 @@ interface KanbanBoardProps { isTeamV2?: boolean; groupBy: GroupBy; emojiLookup?: Map; + taskLookup?: Map; onTaskClick: (task: TeamTaskData) => void; onDeleteTask?: (taskId: string) => void; } -export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, emojiLookup, onTaskClick, onDeleteTask }: KanbanBoardProps) { +export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, emojiLookup, taskLookup, onTaskClick, onDeleteTask }: KanbanBoardProps) { const grouped = useMemo(() => groupTasksBy(tasks, groupBy), [tasks, groupBy]); const columns = useMemo(() => { @@ -39,6 +40,7 @@ export const KanbanBoard = memo(function KanbanBoard({ tasks, isTeamV2, groupBy, tasks={grouped.get(col) ?? []} isTeamV2={isTeamV2} emojiLookup={emojiLookup} + taskLookup={taskLookup} 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 1d714c8c..31b48eda 100644 --- a/ui/web/src/pages/teams/board/kanban-card.tsx +++ b/ui/web/src/pages/teams/board/kanban-card.tsx @@ -1,5 +1,6 @@ import { memo } from "react"; -import { Trash2 } from "lucide-react"; +import { motion } from "framer-motion"; +import { Trash2, Ban } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useTranslation } from "react-i18next"; import { isTaskLocked } from "./board-utils"; @@ -24,20 +25,26 @@ interface KanbanCardProps { task: TeamTaskData; isTeamV2?: boolean; emojiLookup?: Map; + taskLookup?: Map; onClick: () => void; onDelete?: (taskId: string) => void; } -export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup, onClick, onDelete }: KanbanCardProps) { +export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup, taskLookup, 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]!; + const hasBlockers = task.blocked_by && task.blocked_by.length > 0; return ( -
{task.subject}

+ {/* Blocked-by row */} + {hasBlockers && ( +

+ + + {task.blocked_by!.map((id) => taskLookup?.get(id) || id.slice(0, 8)).join(", ")} + +

+ )} + {/* Bottom row: owner + type badge + priority */}
{ownerEmoji && {ownerEmoji}} @@ -95,6 +112,6 @@ export const KanbanCard = memo(function KanbanCard({ task, isTeamV2, emojiLookup {task.progress_percent}%
)} -
+ ); }); diff --git a/ui/web/src/pages/teams/board/kanban-column.tsx b/ui/web/src/pages/teams/board/kanban-column.tsx index c9b63df6..eb96020f 100644 --- a/ui/web/src/pages/teams/board/kanban-column.tsx +++ b/ui/web/src/pages/teams/board/kanban-column.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import { AnimatePresence, LayoutGroup } from "framer-motion"; import { Badge } from "@/components/ui/badge"; import { useTranslation } from "react-i18next"; import { STATUS_COLORS } from "./board-utils"; @@ -11,11 +12,12 @@ interface KanbanColumnProps { tasks: TeamTaskData[]; isTeamV2?: boolean; emojiLookup?: Map; + taskLookup?: Map; onTaskClick: (task: TeamTaskData) => void; onDeleteTask?: (taskId: string) => void; } -export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, isTeamV2, emojiLookup, onTaskClick, onDeleteTask }: KanbanColumnProps) { +export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, isTeamV2, emojiLookup, taskLookup, onTaskClick, onDeleteTask }: KanbanColumnProps) { const { t } = useTranslation("teams"); return ( @@ -30,16 +32,21 @@ export const KanbanColumn = memo(function KanbanColumn({ columnId, title, tasks, {tasks.length === 0 ? (
{t("board.emptyColumn")}
) : ( - tasks.map((task) => ( - onTaskClick(task)} - onDelete={onDeleteTask} - /> - )) + + + {tasks.map((task) => ( + onTaskClick(task)} + onDelete={onDeleteTask} + /> + ))} + + )} 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 6b6f285f..a4289a27 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 @@ -24,11 +24,12 @@ interface TaskDetailDialogProps { taskLookup?: Map; memberLookup?: Map; emojiLookup?: Map; + onNavigateTask?: (taskId: string) => void; } export function TaskDetailDialog({ task, teamId, isTeamV2, onClose, - getTaskDetail, deleteTask, taskLookup, memberLookup, emojiLookup, + getTaskDetail, deleteTask, taskLookup, memberLookup, emojiLookup, onNavigateTask, }: TaskDetailDialogProps) { const { t } = useTranslation("teams"); const [events, setEvents] = useState([]); @@ -171,7 +172,14 @@ export function TaskDetailDialog({ {t("tasks.detail.blockedBy")}{" "}
{task.blocked_by.map((id) => ( - {taskLookup?.get(id) || id.slice(0, 8)} + onNavigateTask(id) : undefined} + > + {taskLookup?.get(id) || id.slice(0, 8)} + ))}
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 186a82ac..32c065c9 100644 --- a/ui/web/src/pages/teams/task-sections/task-list.tsx +++ b/ui/web/src/pages/teams/task-sections/task-list.tsx @@ -120,6 +120,10 @@ export function TaskList({ taskLookup={taskLookup} memberLookup={memberLookup} emojiLookup={emojiLookup} + onNavigateTask={(taskId) => { + const found = tasks.find((t) => t.id === taskId); + if (found) setSelectedTask(found); + }} /> )}