import { Fragment, useState, type ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { ChevronDown, ChevronRight, GitBranch } from "lucide-react"; import { buildIssueTree } from "../lib/issue-tree"; import { issueFilterLabel } from "../lib/issue-filters"; import { allTaskListColumns, defaultTaskListColumns, defaultTaskListSort, sortTaskListItems, type TaskListColumnId, type TaskListSort } from "../lib/task-list"; import { timeAgo } from "../lib/timeAgo"; import { IssueRow, type IssueRowDensity } from "./IssueRow"; import { IssueGroupHeader } from "./IssueGroupHeader"; import { TaskListColumnHeader, TaskListColumnSettings, TaskListViewport } from "./TaskListColumns"; import { TaskRowGroup } from "./TaskRowGroup"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; export type TaskListItem = { issue: Issue; unread?: boolean; createdBy?: string | null; assignee?: string | null; }; export type TaskListGrouping = "none" | "status" | "project"; /** Keep matching subtasks in their hierarchy without pulling in unrelated siblings. */ export function includeTaskListAncestors(items: readonly TaskListItem[], matches: readonly TaskListItem[]): TaskListItem[] { const byId = new Map(items.map(item => [item.issue.id, item])); const included = new Set(); for (const match of matches) { let item: TaskListItem | undefined = match; while (item && !included.has(item.issue.id)) { included.add(item.issue.id); item = item.issue.parentId ? byId.get(item.issue.parentId) : undefined; } } return items.filter(item => included.has(item.issue.id)); } /** Opt-in collection built from the reviewed row, hierarchy and column primitives. */ export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState }: { items: readonly TaskListItem[]; allItems?: readonly TaskListItem[]; initialColumns?: readonly TaskListColumnId[]; density?: IssueRowDensity; collapsedIds?: ReadonlySet; onCollapsedIdsChange?: (ids: ReadonlySet) => void; groupBy?: TaskListGrouping; emptyState?: ReactNode; }) { const [columns, setColumns] = useState(initialColumns); const [columnOrder, setColumnOrder] = useState(allTaskListColumns); const [sort, setSort] = useState(); const [localCollapsed, setLocalCollapsed] = useState>(new Set()); const collapsed = collapsedIds ?? localCollapsed; const setCollapsed = onCollapsedIdsChange ?? setLocalCollapsed; const byId = new Map(allItems.map(item => [item.issue.id, item])); const visibleById = new Map(items.map(item => [item.issue.id, item])); const sortValue = (issue: Issue, column: TaskListColumnId) => { const item = byId.get(issue.id)!; const parent = issue.parentId ? byId.get(issue.parentId)?.issue : null; switch (column) { case "task": return issue.title; case "taskId": return issue.identifier; case "createdBy": return item.createdBy; case "assignee": return item.assignee; case "project": return issue.project?.name; case "parentTask": return parent ? `${parent.identifier} ${parent.title}` : null; case "tags": return issue.labels?.map(label => label.name).sort().join(", "); case "worktree": return issue.currentExecutionWorkspace?.branchName; case "lastUpdated": return new Date(issue.updatedAt).getTime(); } }; const { roots, childMap } = buildIssueTree(sortTaskListItems(items.map(item => item.issue), sort ?? defaultTaskListSort, sortValue)); const updateColumns = (next: readonly TaskListColumnId[]) => { setColumns(next); if (sort && !next.includes(sort.column)) setSort(undefined); }; const renderBranch = (issue: Issue, depth: number): ReactNode => { const item = visibleById.get(issue.id)!; const children = childMap.get(issue.id) ?? []; const expanded = !collapsed.has(issue.id); const parent = issue.parentId ? byId.get(issue.parentId)?.issue : null; const branch = issue.currentExecutionWorkspace?.branchName; return ( 0 && children.length > 0} leadingControl={children.length ? ( ) : undefined} columnValues={{ createdBy: item.createdBy, assignee: item.assignee ?? "Unassigned", project: issue.project?.name, parentTask: parent ? {parent.identifier} {parent.title} : undefined, tags: issue.labels?.length ? {issue.labels.map(label => {label.name})} : undefined, worktree: branch ? {branch} : undefined, lastUpdated: timeAgo(issue.updatedAt), }} /> {expanded ? children.map(child => renderBranch(child, depth + 1)) : null} ); }; const groups = new Map(); for (const root of roots) { const label = groupBy === "status" ? issueFilterLabel(root.status) : groupBy === "project" ? root.project?.name ?? "No project" : ""; groups.set(label, [...(groups.get(label) ?? []), root]); } return ( setSort(undefined)} />}> {items.length === 0 ? emptyState : (
{[...groups].map(([label, groupRoots]) => ( {label ? {groupRoots.length}} /> : null} {groupRoots.map(issue => {renderBranch(issue, 0)})} ))}
)}
); }