Files
openclaw-mission-control/frontend/src/components/organisms/TaskBoard.tsx
2026-02-14 14:22:16 +00:00

528 lines
18 KiB
TypeScript

"use client";
import {
memo,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { TaskCard } from "@/components/molecules/TaskCard";
import { parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils";
type TaskStatus = "inbox" | "in_progress" | "review" | "done";
type Task = {
id: string;
title: string;
status: TaskStatus;
priority: string;
description?: string | null;
due_at?: string | null;
assigned_agent_id?: string | null;
assignee?: string | null;
approvals_pending_count?: number;
tags?: Array<{ id: string; name: string; slug: string; color: string }>;
depends_on_task_ids?: string[];
blocked_by_task_ids?: string[];
is_blocked?: boolean;
};
type TaskBoardProps = {
tasks: Task[];
onTaskSelect?: (task: Task) => void;
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
readOnly?: boolean;
};
type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked";
const columns: Array<{
title: string;
status: TaskStatus;
dot: string;
accent: string;
text: string;
badge: string;
}> = [
{
title: "Inbox",
status: "inbox",
dot: "bg-slate-400",
accent: "hover:border-slate-400 hover:bg-slate-50",
text: "group-hover:text-slate-700 text-slate-500",
badge: "bg-slate-100 text-slate-600",
},
{
title: "In Progress",
status: "in_progress",
dot: "bg-purple-500",
accent: "hover:border-purple-400 hover:bg-purple-50",
text: "group-hover:text-purple-600 text-slate-500",
badge: "bg-purple-100 text-purple-700",
},
{
title: "Review",
status: "review",
dot: "bg-indigo-500",
accent: "hover:border-indigo-400 hover:bg-indigo-50",
text: "group-hover:text-indigo-600 text-slate-500",
badge: "bg-indigo-100 text-indigo-700",
},
{
title: "Done",
status: "done",
dot: "bg-green-500",
accent: "hover:border-green-400 hover:bg-green-50",
text: "group-hover:text-green-600 text-slate-500",
badge: "bg-emerald-100 text-emerald-700",
},
];
/**
* Build compact due-date UI state for a task card.
*
* - Returns `due: undefined` when the task has no due date (or it's invalid), so
* callers can omit the due-date UI entirely.
* - Treats a task as overdue only if it is not `done` (so "Done" tasks don't
* keep showing as overdue forever).
*/
const resolveDueState = (
task: Task,
): { due: string | undefined; isOverdue: boolean } => {
const date = parseApiDatetime(task.due_at);
if (!date) return { due: undefined, isOverdue: false };
const dueLabel = date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
const isOverdue = task.status !== "done" && date.getTime() < Date.now();
return {
due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel,
isOverdue,
};
};
type CardPosition = { left: number; top: number };
const KANBAN_MOVE_ANIMATION_MS = 240;
const KANBAN_MOVE_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)";
/**
* Kanban-style task board with 4 columns.
*
* Notes:
* - Uses a lightweight FLIP animation (via `useLayoutEffect`) to animate cards
* to their new positions when tasks move between columns.
* - Drag interactions can temporarily fight browser-managed drag images; the
* animation is disabled while a card is being dragged.
* - Respects `prefers-reduced-motion`.
*/
export const TaskBoard = memo(function TaskBoard({
tasks,
onTaskSelect,
onTaskMove,
readOnly = false,
}: TaskBoardProps) {
const boardRef = useRef<HTMLDivElement | null>(null);
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const prevPositionsRef = useRef<Map<string, CardPosition>>(new Map());
const animationRafRef = useRef<number | null>(null);
const cleanupTimeoutRef = useRef<number | null>(null);
const animatedTaskIdsRef = useRef<Set<string>>(new Set());
const [draggingId, setDraggingId] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<TaskStatus | null>(null);
const [reviewBucket, setReviewBucket] = useState<ReviewBucket>("all");
const setCardRef = useCallback(
(taskId: string) => (node: HTMLDivElement | null) => {
if (node) {
cardRefs.current.set(taskId, node);
return;
}
cardRefs.current.delete(taskId);
},
[],
);
/**
* Snapshot each card's position relative to the scroll container.
*
* We store these measurements so we can compute deltas (prev - next) and
* apply the FLIP technique on the next render.
*/
const measurePositions = useCallback((): Map<string, CardPosition> => {
const positions = new Map<string, CardPosition>();
const container = boardRef.current;
const containerRect = container?.getBoundingClientRect();
const scrollLeft = container?.scrollLeft ?? 0;
const scrollTop = container?.scrollTop ?? 0;
for (const [taskId, element] of cardRefs.current.entries()) {
const rect = element.getBoundingClientRect();
positions.set(taskId, {
left:
containerRect && container
? rect.left - containerRect.left + scrollLeft
: rect.left,
top:
containerRect && container
? rect.top - containerRect.top + scrollTop
: rect.top,
});
}
return positions;
}, []);
// Animate card reordering smoothly by applying FLIP whenever layout positions change.
useLayoutEffect(() => {
const cardRefsSnapshot = cardRefs.current;
if (animationRafRef.current !== null) {
window.cancelAnimationFrame(animationRafRef.current);
animationRafRef.current = null;
}
if (cleanupTimeoutRef.current !== null) {
window.clearTimeout(cleanupTimeoutRef.current);
cleanupTimeoutRef.current = null;
}
for (const taskId of animatedTaskIdsRef.current) {
const element = cardRefsSnapshot.get(taskId);
if (!element) continue;
element.style.transform = "";
element.style.transition = "";
element.style.willChange = "";
element.style.position = "";
element.style.zIndex = "";
}
animatedTaskIdsRef.current.clear();
const prevPositions = prevPositionsRef.current;
const nextPositions = measurePositions();
prevPositionsRef.current = nextPositions;
// Avoid fighting the browser while it manages the drag image.
if (draggingId) return;
const prefersReducedMotion =
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
if (prefersReducedMotion) return;
const moved: Array<{
taskId: string;
element: HTMLDivElement;
dx: number;
dy: number;
}> = [];
for (const [taskId, next] of nextPositions.entries()) {
const prev = prevPositions.get(taskId);
if (!prev) continue;
const dx = prev.left - next.left;
const dy = prev.top - next.top;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
const element = cardRefsSnapshot.get(taskId);
if (!element) continue;
moved.push({ taskId, element, dx, dy });
}
if (!moved.length) return;
animatedTaskIdsRef.current = new Set(moved.map(({ taskId }) => taskId));
// FLIP: invert to the previous position before paint, then animate back to 0.
for (const { element, dx, dy } of moved) {
element.style.transform = `translate(${dx}px, ${dy}px)`;
element.style.transition = "transform 0s";
element.style.willChange = "transform";
element.style.position = "relative";
element.style.zIndex = "1";
}
animationRafRef.current = window.requestAnimationFrame(() => {
for (const { element } of moved) {
element.style.transition = `transform ${KANBAN_MOVE_ANIMATION_MS}ms ${KANBAN_MOVE_EASING}`;
element.style.transform = "";
}
cleanupTimeoutRef.current = window.setTimeout(() => {
for (const { element } of moved) {
element.style.transition = "";
element.style.willChange = "";
element.style.position = "";
element.style.zIndex = "";
}
animatedTaskIdsRef.current.clear();
cleanupTimeoutRef.current = null;
}, KANBAN_MOVE_ANIMATION_MS + 60);
animationRafRef.current = null;
});
return () => {
if (animationRafRef.current !== null) {
window.cancelAnimationFrame(animationRafRef.current);
animationRafRef.current = null;
}
if (cleanupTimeoutRef.current !== null) {
window.clearTimeout(cleanupTimeoutRef.current);
cleanupTimeoutRef.current = null;
}
for (const taskId of animatedTaskIdsRef.current) {
const element = cardRefsSnapshot.get(taskId);
if (!element) continue;
element.style.transform = "";
element.style.transition = "";
element.style.willChange = "";
element.style.position = "";
element.style.zIndex = "";
}
animatedTaskIdsRef.current.clear();
};
}, [draggingId, measurePositions, tasks]);
const grouped = useMemo(() => {
const buckets: Record<TaskStatus, Task[]> = {
inbox: [],
in_progress: [],
review: [],
done: [],
};
for (const column of columns) {
buckets[column.status] = [];
}
tasks.forEach((task) => {
const bucket = buckets[task.status] ?? buckets.inbox;
bucket.push(task);
});
return buckets;
}, [tasks]);
// Keep drag/drop state and payload handling centralized for column move interactions.
const handleDragStart =
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) {
event.preventDefault();
return;
}
if (task.is_blocked) {
event.preventDefault();
return;
}
setDraggingId(task.id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(
"text/plain",
JSON.stringify({ taskId: task.id, status: task.status }),
);
};
const handleDragEnd = () => {
setDraggingId(null);
setActiveColumn(null);
};
const handleDrop =
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) return;
event.preventDefault();
setActiveColumn(null);
const raw = event.dataTransfer.getData("text/plain");
if (!raw) return;
try {
const payload = JSON.parse(raw) as { taskId?: string; status?: string };
if (!payload.taskId || !payload.status) return;
if (payload.status === status) return;
onTaskMove?.(payload.taskId, status);
} catch {
// Ignore malformed payloads.
}
};
const handleDragOver =
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) return;
event.preventDefault();
if (activeColumn !== status) {
setActiveColumn(status);
}
};
const handleDragLeave = (status: TaskStatus) => () => {
if (readOnly) return;
if (activeColumn === status) {
setActiveColumn(null);
}
};
return (
<div
ref={boardRef}
data-testid="task-board"
className={cn(
// Mobile-first: stack columns vertically to avoid horizontal scrolling.
"grid grid-cols-1 gap-4 overflow-x-hidden pb-6",
// Desktop/tablet: switch back to horizontally scrollable kanban columns.
"sm:grid-flow-col sm:auto-cols-[minmax(260px,320px)] sm:grid-cols-none sm:overflow-x-auto",
)}
>
{columns.map((column) => {
const columnTasks = grouped[column.status] ?? [];
// Derive review tab counts and the active subset from one canonical task list.
const reviewCounts =
column.status === "review"
? columnTasks.reduce(
(acc, task) => {
if (task.is_blocked) {
acc.blocked += 1;
return acc;
}
if ((task.approvals_pending_count ?? 0) > 0) {
acc.approval_needed += 1;
return acc;
}
acc.waiting_lead += 1;
return acc;
},
{
all: columnTasks.length,
approval_needed: 0,
waiting_lead: 0,
blocked: 0,
},
)
: null;
const filteredTasks =
column.status === "review" && reviewBucket !== "all"
? columnTasks.filter((task) => {
if (reviewBucket === "blocked") return Boolean(task.is_blocked);
if (reviewBucket === "approval_needed")
return (
(task.approvals_pending_count ?? 0) > 0 && !task.is_blocked
);
if (reviewBucket === "waiting_lead")
return (
!task.is_blocked &&
(task.approvals_pending_count ?? 0) === 0
);
return true;
})
: columnTasks;
return (
<div
key={column.title}
className={cn(
// On mobile, columns are stacked, so avoid forcing tall fixed heights.
"kanban-column min-h-0",
// On larger screens, keep columns tall to reduce empty space during drag.
"sm:min-h-[calc(100vh-260px)]",
activeColumn === column.status &&
!readOnly &&
"ring-2 ring-slate-200",
)}
onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
>
<div className="column-header z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white px-4 py-3 sm:sticky sm:top-0 sm:bg-white/80 sm:backdrop-blur">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
<h3 className="text-sm font-semibold text-slate-900">
{column.title}
</h3>
</div>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
column.badge,
)}
>
{filteredTasks.length}
</span>
</div>
{column.status === "review" && reviewCounts ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{(
[
{ key: "all", label: "All", count: reviewCounts.all },
{
key: "approval_needed",
label: "Approval needed",
count: reviewCounts.approval_needed,
},
{
key: "waiting_lead",
label: "Lead review",
count: reviewCounts.waiting_lead,
},
{
key: "blocked",
label: "Blocked",
count: reviewCounts.blocked,
},
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => setReviewBucket(option.key)}
className={cn(
"rounded-full border px-2.5 py-1 transition",
reviewBucket === option.key
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
)}
aria-pressed={reviewBucket === option.key}
>
{option.label} · {option.count}
</button>
))}
</div>
) : null}
</div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
<div className="space-y-3">
{filteredTasks.map((task) => {
const dueState = resolveDueState(task);
return (
<div key={task.id} ref={setCardRef(task.id)}>
<TaskCard
title={task.title}
status={task.status}
priority={task.priority}
assignee={task.assignee ?? undefined}
due={dueState.due}
isOverdue={dueState.isOverdue}
approvalsPendingCount={task.approvals_pending_count}
tags={task.tags}
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)}
draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id}
onDragStart={
readOnly ? undefined : handleDragStart(task)
}
onDragEnd={readOnly ? undefined : handleDragEnd}
/>
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
);
});
TaskBoard.displayName = "TaskBoard";