import { memo, useMemo, useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease"; import { useConfirm } from "../hooks/useConfirm"; import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core"; import { COLUMN_LABELS, COLUMN_DESCRIPTIONS, getErrorMessage } from "@fusion/core"; import { TaskCard } from "./TaskCard"; import { WorktreeGroup } from "./WorktreeGroup"; import { QuickEntryBox } from "./QuickEntryBox"; import { PluginSlot } from "./PluginSlot"; import { groupByWorktree } from "../utils/worktreeGrouping"; import type { ToastType } from "../hooks/useToast"; import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react"; import type { ModelInfo, BoardWorkflowColumnFlags } from "../api"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; const PAGINATED_COLUMN_THRESHOLD = 100; const VISIBLE_TASKS_INITIAL = 50; const VISIBLE_TASKS_INCREMENT = 25; /** Shape of a structured transition rejection carried in a 409's `details`. */ interface TransitionRejectionDetail { code: string; messageKey: string; retryable: boolean; } /** * Pull a typed transition rejection out of an `ApiRequestError`'s `details` * (the structured 409 the move/promote endpoints emit under the workflowColumns * flag). Returns null for any other error shape (legacy errors are unchanged). */ export function extractTransitionRejection(err: unknown): TransitionRejectionDetail | null { const details = (err as { details?: Record } | null)?.details; if (!details || typeof details !== "object") return null; const { code, messageKey, retryable } = details as Record; if (typeof code === "string" && typeof messageKey === "string") { return { code, messageKey, retryable: retryable === true }; } return null; } /** * Resolve a rejection (by stable code, falling back to its messageKey) to * user-facing copy. The static `t()` literals here are what the i18next * extractor sees, so the `board.rejection.*` keys persist in the catalog and * the surfaces show real copy rather than a raw key. The `messageKey` carried by * the rejection is still honored as the lookup so a server-chosen non-default * key resolves correctly. */ type TFn = (key: string, defaultValue: string) => string; export function translateRejection(t: TFn, rejection: TransitionRejectionDetail): string { switch (rejection.code) { case "guard-rejected": return t("board.rejection.guardRejected", "This move is not allowed by the workflow."); case "capacity-exhausted": return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."); case "unknown-column": return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."); case "workflow-mismatch": return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."); case "merge-blocked": return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."); default: return t(rejection.messageKey, rejection.messageKey); } } /** Translate a bare drag pre-check messageKey (R17 no-move) to copy. The same * static literals as {@link translateRejection} so the extractor keeps them. */ export function translateRejectionKey(t: TFn, messageKey: string): string { switch (messageKey) { case "board.rejection.guardRejected": return t("board.rejection.guardRejected", "This move is not allowed by the workflow."); case "board.rejection.capacityExhausted": return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."); case "board.rejection.unknownColumn": return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."); case "board.rejection.workflowMismatch": return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."); case "board.rejection.mergeBlocked": return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."); default: return t(messageKey, messageKey); } } interface ColumnProps { column: ColumnType; tasks: Task[]; projectId?: string; maxConcurrent: number; onMoveTask: (id: string, column: ColumnType, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onPauseTask?: (id: string) => Promise; onOpenDetail: (task: Task | TaskDetail) => void; onOpenGroupModal?: (groupId: string) => void; addToast: (message: string, type?: ToastType) => void; onQuickCreate?: (input: TaskCreateInput) => Promise; onNewTask?: () => void; autoMerge?: boolean; onToggleAutoMerge?: () => void; globalPaused?: boolean; onUpdateTask?: ( id: string, updates: { title?: string; description?: string; dependencies?: string[] } ) => Promise; onRetryTask?: (id: string) => Promise; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; onUnarchiveTask?: (id: string) => Promise; onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; }) => Promise; onArchiveAllDone?: () => Promise; collapsed?: boolean; onToggleCollapse?: () => void; allTasks?: Task[]; availableModels?: ModelInfo[]; /** * Called when the user clicks the "Plan" button in the inline create card. */ onPlanningMode?: (initialPlan: string) => void; /** * Called when the user clicks the "Subtask" button in the inline create card. */ onSubtaskBreakdown?: (description: string) => void; onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => void; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; /** When true, search is active — bypass pagination so all matching tasks are visible. */ isSearchActive?: boolean; /** Project-level stuck task timeout in milliseconds (undefined = disabled) */ taskStuckTimeoutMs?: number; /** Called when user clicks a mission badge on a task card */ onOpenMission?: (missionId: string) => void; /** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */ lastFetchTimeMs?: number; /** Lookup of workflow step IDs to display names, fetched once at board level. */ workflowStepNameLookup?: ReadonlyMap; /** Per-task card-placed custom field definitions (U13/KTD-14). */ taskCardFieldDefs?: ReadonlyMap; /** Precomputed blocker fanout keyed by blocker task ID. */ blockerFanoutMap?: ReadonlyMap; /** Whether GitHub CLI auth is available for creating PRs from task cards. */ prAuthAvailable?: boolean; // ── U9 workflow-columns (flag-ON) additive props ───────────────────────── /** True when the board is in multi-lane workflow mode (flag ON). Switches * column behavior (label, bulk actions, archived detection) from legacy * literals to trait-flag predicates. Flag OFF leaves all behavior legacy. */ workflowMode?: boolean; /** Workflow id for column-aware task creation in workflow mode. */ workflowId?: string; /** Display name for this column, from the workflow definition. */ columnDisplayName?: string; /** Resolved trait flags for this column (workflow mode). */ columnFlags?: BoardWorkflowColumnFlags; /** Manually promote a held card out of this hold column (workflow mode). */ onPromote?: (taskId: string) => Promise; /** * Pre-check whether a drop into THIS column is allowed for the dragged task. * Returns null for "allowed", or an i18n messageKey for a deterministic * rejection (guard/capacity/unknown-column/workflow-mismatch). When a * rejection is returned, dragover is NOT prevented, so the card never renders * in this column (no-move semantics, R17). The dragged task id is read from a * board-level ref set on dragstart. */ canDropTask?: (taskId: string) => string | null; /** Read the id of the task currently being dragged (board-level ref). */ getDraggingTaskId?: () => string | null; } function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { const { t } = useTranslation("app"); // Anchor the board.rejection.* catalog keys for the i18next extractor (it // scopes `t` to the useTranslation binding, so the shared translateRejection // helper's calls are not statically discovered). These resolve the same copy. const rejectionCopy = useMemo(() => ({ guardRejected: t("board.rejection.guardRejected", "This move is not allowed by the workflow."), capacityExhausted: t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."), unknownColumn: t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."), workflowMismatch: t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."), mergeBlocked: t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."), promoteRejected: t("board.rejection.promoteRejected", "This card could not be promoted."), }), [t]); void rejectionCopy; const [dragOver, setDragOver] = useState(false); const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isReplanning, setIsReplanning] = useState(false); const [isPausingAll, setIsPausingAll] = useState(false); const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false); // Workflow mode: per-card promote in-flight ids + inline capacity feedback. const [promotingIds, setPromotingIds] = useState>(() => new Set()); const [inlineFeedback, setInlineFeedback] = useState(null); const menuRef = useRef(null); const countFlashing = useFlashOnIncrease(tasks.length); const { confirm } = useConfirm(); // Clear the inline capacity-exhausted banner once the column's task list // changes via SSE (e.g. an occupant moves out and capacity frees up). The // banner reflects a point-in-time promote rejection; a changed roster means // the stale constraint may no longer hold. Keyed on the task-id signature so // it only fires on real membership changes, not every parent re-render. const taskIdSignature = useMemo(() => tasks.map((task) => task.id).join(","), [tasks]); useEffect(() => { setInlineFeedback(null); }, [taskIdSignature]); // Close the column dropdown menu when the user clicks anywhere else. useEffect(() => { if (!isMenuOpen) return; function onDocClick(e: MouseEvent) { if (!menuRef.current?.contains(e.target as Node)) { setIsMenuOpen(false); } } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setIsMenuOpen(false); } document.addEventListener("mousedown", onDocClick); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDocClick); document.removeEventListener("keydown", onKey); }; }, [isMenuOpen]); // Archived column is collapsed by default - don't show drag state when collapsed. // Workflow mode keys off the resolved `archived` trait flag instead of the // literal column id (R9). A hold-flagged column shows the promote affordance. const isArchived = workflowMode ? Boolean(columnFlags?.archived) : column === "archived"; const isHoldColumn = workflowMode && Boolean(columnFlags?.hold); const isCollapsed = isArchived && collapsed; // Legacy in-progress renders worktree groups (not paginated); in workflow // mode there is no special-casing, so a processing column paginates normally. const isLegacyInProgress = !workflowMode && column === "in-progress"; // When search is active, skip pagination so all matching tasks are visible const shouldPaginate = !isArchived && !isSearchActive && !isLegacyInProgress && tasks.length > PAGINATED_COLUMN_THRESHOLD; useEffect(() => { setVisibleTaskCount((current) => { if (isLegacyInProgress || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) { return VISIBLE_TASKS_INITIAL; } return Math.min(Math.max(current, VISIBLE_TASKS_INITIAL), tasks.length); }); }, [isLegacyInProgress, isArchived, tasks.length]); const handleDragOver = useCallback((e: React.DragEvent) => { // Don't allow dropping into archived column via drag-drop if (isArchived) return; // Workflow mode (R17): deterministic rejections are NO-MOVE — we do NOT // call preventDefault, so the browser refuses the drop and the card never // renders in this column. A null result means the drop is allowed. if (workflowMode && canDropTask && getDraggingTaskId) { const draggingId = getDraggingTaskId(); if (draggingId) { const rejectionKey = canDropTask(draggingId); if (rejectionKey) { setInlineFeedback(translateRejectionKey(t, rejectionKey)); return; // no preventDefault → no-move } } } e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOver(true); }, [isArchived, workflowMode, canDropTask, getDraggingTaskId, t]); const handleDragLeave = useCallback((e: React.DragEvent) => { const el = e.currentTarget as HTMLElement; if (!el.contains(e.relatedTarget as Node)) { setDragOver(false); setInlineFeedback(null); } }, []); const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); setDragOver(false); const taskId = e.dataTransfer.getData("text/plain"); if (!taskId) return; // Check if task is already in this column - if so, skip the API call const task = tasks.find((t) => t.id === taskId); if (task && task.column === column) { return; // No-op: task is already in this column } try { const sourceTask = allTasks?.find((t) => t.id === taskId) ?? task; const hasStepProgress = sourceTask?.steps.some((step) => step.status !== "pending") ?? false; const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress; let moveOptions: { preserveProgress?: boolean } | undefined; if (shouldPrompt) { const keepProgress = await confirm({ title: t("column.preserveProgressTitle", "Preserve Progress?"), message: t("column.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"), confirmLabel: t("column.keepProgress", "Keep Progress"), cancelLabel: t("column.resetProgress", "Reset Progress"), }); if (keepProgress) { moveOptions = { preserveProgress: true }; } else { const resetProgress = await confirm({ title: t("column.resetProgressTitle", "Reset Progress?"), message: t("column.resetProgressMessage", "Reset all step progress before moving this task?"), confirmLabel: t("column.resetProgressConfirm", "Reset Progress"), cancelLabel: t("column.cancelMove", "Cancel Move"), danger: true, }); if (!resetProgress) { return; } } } await onMoveTask(taskId, column, moveOptions); } catch (err) { // Workflow mode (R17): a structured 409 carries a typed rejection. The // optimistic move snaps back automatically (the next SSE/refresh restores // the card's real column); surface the translated rejection messageKey. const rejection = extractTransitionRejection(err); if (rejection) { addToast(translateRejection(t, rejection), "error"); } else { addToast(getErrorMessage(err), "error"); } } }, [addToast, allTasks, column, confirm, onMoveTask, tasks, t]); const handlePromote = useCallback(async (taskId: string) => { if (!onPromote) return; setInlineFeedback(null); setPromotingIds((prev) => { const next = new Set(prev); next.add(taskId); return next; }); try { await onPromote(taskId); } catch (err) { const rejection = extractTransitionRejection(err); if (rejection) { // Capacity-exhausted (and any rejection) shows INLINE column feedback, // not a toast — so multiple holds can promote concurrently without spam. setInlineFeedback(translateRejection(t, rejection)); } else { setInlineFeedback(getErrorMessage(err)); } } finally { setPromotingIds((prev) => { const next = new Set(prev); next.delete(taskId); return next; }); } }, [onPromote, t]); // Worktree grouping is a legacy in-progress affordance; in workflow mode a // custom processing column renders plain cards (KTD-11 keeps one-card-one-lane). const worktreeGroups = useMemo(() => { if (!isLegacyInProgress) return []; return groupByWorktree(tasks, tasks, maxConcurrent); }, [isLegacyInProgress, tasks, maxConcurrent]); const visibleTasks = useMemo(() => { if (!shouldPaginate) return tasks; return tasks.slice(0, visibleTaskCount); }, [shouldPaginate, tasks, visibleTaskCount]); const hiddenTaskCount = Math.max(0, tasks.length - visibleTasks.length); const canCreateInColumn = Boolean( onQuickCreate && !isArchived && (workflowMode || column === "triage"), ); const handleQuickCreate = useCallback( (input: TaskCreateInput) => { if (!onQuickCreate) return Promise.resolve(); if (workflowMode) { const explicitWorkflowId = workflowId?.startsWith("builtin:") ? undefined : workflowId; return onQuickCreate({ ...input, column, ...(explicitWorkflowId ? { workflowId: explicitWorkflowId } : {}), }); } return onQuickCreate(input); }, [column, onQuickCreate, workflowId, workflowMode], ); const handleLoadMore = useCallback(() => { setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length)); }, [tasks.length]); const handleReplanAll = useCallback(async () => { setIsMenuOpen(false); if (tasks.length === 0) return; const confirmed = await confirm({ title: t("column.replanAllTitle", "Replan All Tasks"), message: t("column.replanAllMessage", "Move all {{count}} todo task{{plural}} back to planning to be replanned?", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" }), }); if (!confirmed) return; setIsReplanning(true); try { // Issue moves in parallel — onMoveTask is per-task, no bulk endpoint. const results = await Promise.allSettled( tasks.map((task) => onMoveTask(task.id, "triage" as ColumnType)), ); const failed = results.filter((r) => r.status === "rejected").length; const moved = results.length - failed; if (failed === 0) { addToast(t("column.movedToPlanning", "Moved {{count}} task{{plural}} to planning for replanning", { count: moved, plural: moved === 1 ? "" : "s" }), "success"); } else { addToast(t("column.movePartialFailure", "Moved {{moved}} of {{total}} tasks; {{failed}} failed", { moved, total: results.length, failed }), "error"); } } finally { setIsReplanning(false); } }, [tasks, onMoveTask, addToast, confirm]); const pauseEligibleTasks = useMemo( () => tasks.filter((task) => !task.paused && !task.assignedAgentId), [tasks], ); const pauseEligibleCount = pauseEligibleTasks.length; // Bulk-action eligibility (R9): workflow mode keys off trait flags instead of // the literal column ids. Todo-equivalent = hold/intake (replan affordance); // processing = wip/countsTowardWip; review = mergeBlocker/humanReview. const isTodoLikeColumn = workflowMode ? Boolean(columnFlags?.hold || columnFlags?.intake) : column === "todo"; const isProcessingColumn = workflowMode ? Boolean(columnFlags?.countsTowardWip) : column === "in-progress"; const isReviewColumn = workflowMode ? Boolean(columnFlags?.mergeBlocker || columnFlags?.humanReview) : column === "in-review"; const hasColumnBulkActions = isTodoLikeColumn || isProcessingColumn || isReviewColumn; const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo; const columnLabelText = workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column]; const handlePauseAll = useCallback(async () => { if (!onPauseTask) return; setIsMenuOpen(false); if (pauseEligibleCount === 0) return; const confirmed = await confirm({ title: t("column.stopAllTitle", "Stop All Tasks"), message: t("column.stopAllMessage", "Stop all {{count}} {{columnLabel}} task{{plural}}?", { count: pauseEligibleCount, columnLabel: columnLabelText.toLowerCase(), plural: pauseEligibleCount === 1 ? "" : "s" }), danger: true, }); if (!confirmed) return; setIsPausingAll(true); try { const results = await Promise.allSettled( pauseEligibleTasks.map((task) => onPauseTask(task.id)), ); const failed = results.filter((r) => r.status === "rejected").length; const paused = results.length - failed; if (failed === 0) { addToast(t("column.stoppedTasks", "Stopped {{count}} task{{plural}}", { count: paused, plural: paused === 1 ? "" : "s" }), "success"); } else { addToast(t("column.stopPartialFailure", "Stopped {{paused}} of {{total}} tasks; {{failed}} failed", { paused, total: results.length, failed }), "error"); } } finally { setIsPausingAll(false); } }, [onPauseTask, pauseEligibleCount, columnLabelText, pauseEligibleTasks, addToast, confirm, t]); const handleMoveAllToTodo = useCallback(async () => { setIsMenuOpen(false); if (tasks.length === 0) return; const confirmed = await confirm({ title: t("column.moveAllToTodoTitle", "Move All to Todo"), message: t("column.moveAllToTodoMessage", "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", { count: tasks.length, columnLabel: columnLabelText.toLowerCase(), plural: tasks.length === 1 ? "" : "s" }), }); if (!confirmed) return; const hasAnyProgress = tasks.some((task) => task.steps.some((step) => step.status !== "pending")); let preserveProgress = false; if (hasAnyProgress) { const keepProgress = await confirm({ title: t("column.preserveProgressTitle", "Preserve Progress?"), message: t("column.preserveProgressMoveTodoMessage", "Some tasks have completed steps. Keep progress before moving to Todo?"), confirmLabel: t("column.keepProgress", "Keep Progress"), cancelLabel: t("column.resetProgress", "Reset Progress"), }); if (keepProgress) { preserveProgress = true; } else { const resetProgress = await confirm({ title: t("column.resetProgressTitle", "Reset Progress?"), message: t("column.resetProgressMoveTodoMessage", "Reset step progress for tasks before moving to Todo?"), confirmLabel: t("column.resetProgressConfirm", "Reset Progress"), cancelLabel: t("column.cancelMove", "Cancel Move"), danger: true, }); if (!resetProgress) { return; } } } setIsMovingAllToTodo(true); try { const results = await Promise.allSettled( tasks.map((task) => onMoveTask(task.id, "todo", preserveProgress ? { preserveProgress: true } : undefined)), ); const failed = results.filter((r) => r.status === "rejected").length; const moved = results.length - failed; if (failed === 0) { addToast(t("column.movedToTodo", "Moved {{count}} task{{plural}} to Todo", { count: moved, plural: moved === 1 ? "" : "s" }), "success"); } else { addToast(t("column.moveToTodoPartialFailure", "Moved {{moved}} of {{total}} tasks to Todo; {{failed}} failed", { moved, total: results.length, failed }), "error"); } } finally { setIsMovingAllToTodo(false); } }, [tasks, columnLabelText, onMoveTask, addToast, confirm, t]); const handleArchiveAll = useCallback(async () => { if (!onArchiveAllDone) return; if (tasks.length === 0) return; const confirmed = await confirm({ title: t("column.archiveAllTitle", "Archive All Done"), message: t("column.archiveAllMessage", "Archive all {{count}} done tasks?", { count: tasks.length }), danger: true, }); if (!confirmed) return; try { const archived = await onArchiveAllDone(); addToast(t("column.archivedTasks", "Archived {{count}} tasks", { count: archived.length }), "success"); } catch (err) { addToast(getErrorMessage(err) || t("column.failedToArchive", "Failed to archive tasks"), "error"); } }, [onArchiveAllDone, tasks.length, addToast, confirm, t]); return (

{workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column]}

{tasks.length} {(workflowMode ? isReviewColumn : column === "in-review") && onToggleAutoMerge && ( )} {onNewTask && ( )} {column === "done" && onArchiveAllDone && ( )} {isArchived && onToggleCollapse && ( )} {hasColumnBulkActions && (
{isMenuOpen && (
{isTodoLikeColumn && ( )} {(isProcessingColumn || isReviewColumn) && ( <> )}
)}
)}
{!isCollapsed && (workflowMode ? COLUMN_DESCRIPTIONS[column] !== undefined : true) && (

{COLUMN_DESCRIPTIONS[column]}

)} {!isCollapsed && inlineFeedback && (

{inlineFeedback}

)} {!isCollapsed && (
{canCreateInColumn && ( { const matchingTask = (allTasks ?? []).find((candidate) => candidate.id === taskId); if (matchingTask) { onOpenDetail(matchingTask); return; } if (typeof window !== "undefined") { window.location.hash = `#/tasks/${taskId}`; } }} /> )} {isLegacyInProgress ? ( worktreeGroups.length === 0 ? (
{t("column.noTasks", "No tasks")}
) : ( worktreeGroups.map((group) => ( )) ) ) : tasks.length === 0 ? (
{t("column.noTasks", "No tasks")}
) : ( <> {visibleTasks.map((task) => (
{isHoldColumn && onPromote && ( )}
))} {shouldPaginate && hiddenTaskCount > 0 && ( )} )}
)}
); } export const Column = memo(ColumnComponent); Column.displayName = "Column";