import { CardStatusLine } from "./CardStatusIcons"; import { Badge, Box, Button, Paper, Stack, Text, Title, Tooltip } from "@mantine/core"; import { useLayoutEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react"; import { IconArrowUpRight, IconFolder } from "@tabler/icons-react"; import { hasLinkedCodexThread, latestTurnPresentation } from "../state.js"; import type { Task, TaskStatus } from "../types"; import { LinkedText } from "./LinkedText"; import { TurnContent } from "./TurnContent"; import { QueuedPrompts } from "./QueuedPrompts"; import { ReplyCover } from "./ReplyCover"; import { ElapsedTime, RelativeTime } from "./RelativeTime"; const defaultLanes: { status: TaskStatus; label: string; emptyMessage?: string }[] = [ { status: "working", label: "Working" }, { status: "needs_input", label: "Needs input" }, { status: "completed", label: "Completed" }, { status: "failed", label: "Failed" }, { status: null, label: "Unresolved" }, ]; export function TaskBoard({ lanes = defaultLanes, loading = false, showCardUsage = true, groupInterruptedWithWaiting = false, loadImage, doneNotice, onVisibleTasksChange, onMoveTask, completedLimit, archivedLimit = 5, onMoreArchived, onMoreCompleted, onOpenCodex, onOpenDetail, tasks, }: { loading?: boolean; showCardUsage?: boolean; onMoveTask?: (task: Task, destination: TaskStatus) => void; groupInterruptedWithWaiting?: boolean; doneNotice?: ReactNode; onVisibleTasksChange?: (ids: string[]) => void; loadImage?: (task: Task, url?: string) => Promise; lanes?: { status: TaskStatus; label: string; emptyMessage?: string }[]; completedLimit: number; archivedLimit?: number; onMoreArchived?: () => void; onMoreCompleted: () => void; onOpenCodex: (task: Task) => void; onOpenDetail: (task: Task) => void; tasks: Task[]; }) { const draggedTask = useRef(null); const [dragging, setDragging] = useState(null); const isWaiting = (task: Task) => ["needs_input", "interrupted"].includes(task.status ?? ""); const canDrop = (task: Task, destination: TaskStatus) => destination === "archived" || (isWaiting(task) && (destination === "completed" ? !task.scheduled && !task.pullRequests?.some(pr => pr.state !== "merged") : destination === "scheduled" && !!task.scheduled && task.inputSource === "ordinary" && !!task.turnId)); const acceptsDrop = (task: Task, destination: TaskStatus) => destination === "archived" || (isWaiting(task) && ["completed", "scheduled"].includes(destination ?? "")); const boardRef = useRef(null); const previousPositions = useRef(new Map()); const dragRef = useRef<{ pointerId: number; startX: number; scrollLeft: number; moved: boolean } | null>(null); const suppressClickRef = useRef(false); // Observe the same DOM cards across reply updates; rebuild only when cards or lanes change. const cardLayoutKey = JSON.stringify(tasks.map(({ id, status }) => [id, status])); useLayoutEffect(() => { const cards = boardRef.current?.querySelectorAll("[data-chat-id]"); if (!cards) return; const next = new Map(); const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; for (const card of cards) { for (const animation of card.getAnimations?.() ?? []) animation.cancel(); const rect = card.getBoundingClientRect(); const left = rect.left + (boardRef.current?.scrollLeft ?? 0); const top = rect.top + (boardRef.current?.scrollTop ?? 0); const id = card.dataset.chatId!; const previous = previousPositions.current.get(id); next.set(id, { left, top }); if (previous && !reducedMotion && typeof card.animate === "function") { const x = previous.left - left; const y = previous.top - top; if (Math.abs(x) > 1 || Math.abs(y) > 1) card.animate([ { transform: `translate(${x}px, ${y}px)` }, { transform: "translate(0, 0)" }, ], { duration: 280, easing: "ease-out" }); } } previousPositions.current = next; }, [cardLayoutKey, completedLimit, archivedLimit]); useLayoutEffect(() => { if (!onVisibleTasksChange || !boardRef.current || typeof IntersectionObserver === "undefined") return; onVisibleTasksChange([]); const visible = new Set(); let active = true; const observer = new IntersectionObserver(entries => { if (!active) return; for (const entry of entries) { const id = (entry.target as HTMLElement).dataset.chatId; if (!id) continue; if (entry.isIntersecting) visible.add(id); else visible.delete(id); } onVisibleTasksChange([...visible].sort()); }); const cards = boardRef.current.querySelectorAll("[data-chat-id]"); for (const card of cards) observer.observe(card); return () => { active = false; observer.disconnect(); onVisibleTasksChange([]); }; }, [cardLayoutKey, completedLimit, archivedLimit, onVisibleTasksChange]); function onPointerDown(event: ReactPointerEvent) { if (event.pointerType !== "mouse" || event.button !== 0 || !event.isPrimary) return; const target = event.target as Element; if (target.closest("button, a, input, textarea, select, [contenteditable], .taskchef-board-card, h1, h2, h3, p, span")) return; const board = boardRef.current; if (!board || board.scrollWidth <= board.clientWidth) return; dragRef.current = { pointerId: event.pointerId, startX: event.clientX, scrollLeft: board.scrollLeft, moved: false }; board.setPointerCapture(event.pointerId); } function onPointerMove(event: ReactPointerEvent) { const drag = dragRef.current; const board = boardRef.current; if (!drag || !board || drag.pointerId !== event.pointerId) return; const delta = event.clientX - drag.startX; if (!drag.moved && Math.abs(delta) < 6) return; if (!drag.moved) { drag.moved = true; board.classList.add("taskchef-board-dragging"); } event.preventDefault(); board.scrollLeft = drag.scrollLeft - delta; } function endPointer(event: ReactPointerEvent) { const drag = dragRef.current; const board = boardRef.current; if (!drag || !board || drag.pointerId !== event.pointerId) return; dragRef.current = null; board.classList.remove("taskchef-board-dragging"); if (board.hasPointerCapture(event.pointerId)) board.releasePointerCapture(event.pointerId); if (drag.moved) { suppressClickRef.current = true; window.setTimeout(() => { suppressClickRef.current = false; }, 0); } } const knownStatuses = new Set(lanes.map((lane) => lane.status)); const laneFor = (task: Task): TaskStatus => groupInterruptedWithWaiting && task.status === "interrupted" ? "needs_input" : knownStatuses.has(task.status) ? task.status : null; const visibleLanes = lanes.filter((lane) => (lane.status !== null || tasks.some((task) => laneFor(task) === null))); return ( { if (!suppressClickRef.current) return; event.preventDefault(); event.stopPropagation(); suppressClickRef.current = false; }} onPointerCancel={endPointer} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endPointer} ref={boardRef} tabIndex={0} > {visibleLanes.map(({ status, label, emptyMessage }) => { const matching = tasks.filter((task) => laneFor(task) === status); const shown = (status === "completed" || status === "archived") ? matching.slice(0, status === "archived" ? archivedLimit : completedLimit) : matching; return ( { if (onMoveTask && draggedTask.current && acceptsDrop(draggedTask.current, status)) event.preventDefault(); }} onDrop={(event) => { const task = draggedTask.current; draggedTask.current = null; setDragging(null); if (!onMoveTask || !task || !acceptsDrop(task, status)) return; event.preventDefault(); onMoveTask(task, status); }} > {label} {status === "working" && label === "Running" && matching.length > 0 && ( )} {matching.length > 0 && {matching.length}} {!loading && status === "completed" && doneNotice} {shown.map((task) => { if (event.target !== event.currentTarget) { if (!(event.target as Element).closest("a, img")) return; const rect = event.currentTarget.getBoundingClientRect(); event.dataTransfer.setDragImage?.(event.currentTarget, event.clientX - rect.left, event.clientY - rect.top); } draggedTask.current = task; setDragging(task); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", task.id); }} onDragEnd={() => { draggedTask.current = null; setDragging(null); }} loadImage={loadImage} key={task.id} onOpenCodex={onOpenCodex} onOpenDetail={onOpenDetail} task={task} />)} {matching.length === 0 && {loading ? "Loading…" : emptyMessage ?? "No tasks"}} {(status === "completed" || status === "archived") && matching.length > shown.length && ( )} ); })} ); } function BoardCard({ showCardUsage, task, onOpenCodex, onOpenDetail, loadImage, draggable, onDragStart, onDragEnd }: { showCardUsage: boolean; draggable?: boolean; onDragStart?: React.DragEventHandler; onDragEnd?: React.DragEventHandler; loadImage?: (task: Task, url?: string) => Promise; task: Task; onOpenCodex: (task: Task) => void; onOpenDetail: (task: Task) => void; }) { const latest = latestTurnPresentation(task); const excerpt = task.status === "working" ? latest.requestSummary : latest.resultSummary; const linked = hasLinkedCodexThread(task); const cover = task.replyImage ?? task.promptImage; return ( {task.observed && cover && onOpenDetail(task)} />} <button className="taskchef-title-button" onClick={() => onOpenDetail(task)} type="button">{task.title}</button> {task.observed?.unread && <span className="taskchef-chat-unread-dot" role="img" aria-label="Unread chat" />} {task.project.name} {task.needsAnswer && Needs answer} {task.status === "interrupted" && Interrupted} {task.observed ? : } onOpenDetail(task)} /> {task.observed && task.status === "working" ? : } {linked ? ( ) : task.status === "working" ? Chat link pending : null} ); }