From 3aa8d2d28b9e7bbac764ddb9b575377928a39794 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 01:49:51 -0700 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20flag-gated=20multi-lane=20bo?= =?UTF-8?q?ard=20=E2=80=94=20lane=20per=20workflow,=20trait-keyed=20column?= =?UTF-8?q?s,=20typed=20drag=20rejections,=20hold=20promote=20(U9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/dashboard/app/components/Board.tsx | 181 +- packages/dashboard/app/components/Column.tsx | 277 +- packages/dashboard/app/components/Lane.css | 97 + packages/dashboard/app/components/Lane.tsx | 215 + .../dashboard/app/components/TaskCard.tsx | 29 + .../app/components/WorkflowSelector.tsx | 27 +- .../app/components/__tests__/Board.test.tsx | 166 + .../app/components/__tests__/Column.test.tsx | 72 + .../app/components/__tests__/Lane.test.tsx | 154 + .../components/__tests__/TaskCard.test.tsx | 18 + .../__tests__/WorkflowSelector.test.tsx | 56 + .../routes/__tests__/board-workflows.test.ts | 103 + .../dashboard/src/routes/board-workflows.ts | 168 + .../routes/register-task-workflow-routes.ts | 88 +- packages/i18n/locales/en/app.json | 1569 ++-- packages/i18n/locales/en/cli.json | 31 +- packages/i18n/locales/en/common.json | 228 +- packages/i18n/locales/en/errors.json | 5 +- packages/i18n/locales/es/app.json | 1569 ++-- packages/i18n/locales/es/cli.json | 31 +- packages/i18n/locales/es/common.json | 228 +- packages/i18n/locales/es/errors.json | 5 +- packages/i18n/locales/fr/app.json | 1569 ++-- packages/i18n/locales/fr/cli.json | 31 +- packages/i18n/locales/fr/common.json | 228 +- packages/i18n/locales/fr/errors.json | 5 +- packages/i18n/locales/ko/app.json | 1627 ++-- packages/i18n/locales/ko/cli.json | 33 +- packages/i18n/locales/ko/common.json | 228 +- packages/i18n/locales/ko/errors.json | 5 +- packages/i18n/locales/zh-CN/app.json | 1627 ++-- packages/i18n/locales/zh-CN/cli.json | 33 +- packages/i18n/locales/zh-CN/common.json | 228 +- packages/i18n/locales/zh-CN/errors.json | 5 +- packages/i18n/locales/zh-TW/app.json | 1627 ++-- packages/i18n/locales/zh-TW/cli.json | 33 +- packages/i18n/locales/zh-TW/common.json | 228 +- packages/i18n/locales/zh-TW/errors.json | 5 +- packages/i18n/src/i18next-resources.d.ts | 10 + packages/i18n/src/resources.d.ts | 7214 +++++++++++++++++ 40 files changed, 14846 insertions(+), 5207 deletions(-) create mode 100644 packages/dashboard/app/components/Lane.css create mode 100644 packages/dashboard/app/components/Lane.tsx create mode 100644 packages/dashboard/app/components/__tests__/Lane.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/WorkflowSelector.test.tsx create mode 100644 packages/dashboard/src/routes/__tests__/board-workflows.test.ts create mode 100644 packages/dashboard/src/routes/board-workflows.ts create mode 100644 packages/i18n/src/i18next-resources.d.ts create mode 100644 packages/i18n/src/resources.d.ts diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 423c39df46..d2fe8ca8fc 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -2,12 +2,16 @@ import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIss import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core"; import { sortTasksForDisplayColumn } from "./taskSorting"; import { Column } from "./Column"; +import { Lane } from "./Lane"; import type { ToastType } from "../hooks/useToast"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; -import { fetchWorkflowSteps, type ModelInfo } from "../api"; +import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api"; import { useBlockerFanout } from "../hooks/useBlockerFanout"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; +/** localStorage key for persisted lane collapse state (per project). */ +const LANE_COLLAPSE_STORAGE_KEY = "kb-dashboard-lane-collapsed"; + interface BoardProps { tasks: Task[]; projectId?: string; @@ -261,10 +265,185 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask }; }, []); + // ── U9 multi-lane board (flag-gated) ────────────────────────────────────── + // Fetch board-workflows metadata. When the flag is OFF the server returns + // { flagEnabled: false } and we render the legacy single-lane board below. + const [boardWorkflows, setBoardWorkflows] = useState(null); + const draggingTaskIdRef = useRef(null); + const [collapsedLanes, setCollapsedLanes] = useState>(() => { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(LANE_COLLAPSE_STORAGE_KEY); + const parsed = raw ? (JSON.parse(raw) as unknown) : null; + if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === "string")); + } catch { + /* ignore corrupt persisted state */ + } + return new Set(); + }); + + useEffect(() => { + let cancelled = false; + fetchBoardWorkflows(projectId) + .then((payload) => { + if (!cancelled) setBoardWorkflows(payload); + }) + .catch(() => { + if (!cancelled) setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); + }); + return () => { + cancelled = true; + }; + }, [projectId, tasks]); + + const handleToggleLaneCollapse = useCallback((workflowId: string) => { + setCollapsedLanes((prev) => { + const next = new Set(prev); + if (next.has(workflowId)) next.delete(workflowId); + else next.add(workflowId); + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(LANE_COLLAPSE_STORAGE_KEY, JSON.stringify([...next])); + } catch { + /* ignore quota / serialization errors */ + } + } + return next; + }); + }, []); + + const handlePromote = useCallback(async (taskId: string) => { + await promoteTask(taskId, projectId); + }, [projectId]); + + const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []); + + const flagOn = boardWorkflows?.flagEnabled === true; + + // Group visible tasks into lanes by resolved workflow (null → default lane). + const lanes = useMemo(() => { + if (!boardWorkflows || !flagOn) return []; + const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows; + const byId = new Map(workflows.map((w) => [w.id, w] as const)); + const tasksByWorkflow = new Map(); + for (const task of tasks) { + // Archived cards are excluded from lanes (archived columns are hidden). + if (task.column === "archived") continue; + const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId; + (tasksByWorkflow.get(workflowId) ?? tasksByWorkflow.set(workflowId, []).get(workflowId)!).push(task); + } + const result: Array<{ workflow: typeof workflows[number]; tasks: Task[] }> = []; + for (const [workflowId, laneTasks] of tasksByWorkflow) { + const workflow = byId.get(workflowId); + if (!workflow) continue; + if (laneTasks.length === 0) continue; // zero-card lanes hidden + result.push({ workflow, tasks: laneTasks }); + } + // Default lane first; then by workflow name for stable ordering. + result.sort((a, b) => { + if (a.workflow.id === defaultWorkflowId) return -1; + if (b.workflow.id === defaultWorkflowId) return 1; + return a.workflow.name.localeCompare(b.workflow.name); + }); + return result; + }, [boardWorkflows, flagOn, tasks]); + + // Drag pre-check (R17): adjacency + capacity from the lane's column metadata. + // Cross-lane drag → workflow-mismatch. Deterministic rejections return a + // messageKey (no-move); null = allowed. + const canDropTask = useCallback((taskId: string, targetColumnId: string, laneWorkflowId: string): string | null => { + if (!boardWorkflows) return null; + const sourceTask = tasks.find((t) => t.id === taskId); + if (!sourceTask) return null; + const sourceWorkflowId = boardWorkflows.taskWorkflowIds[taskId] ?? boardWorkflows.defaultWorkflowId; + // Cross-lane drag never switches workflows (R17). + if (sourceWorkflowId !== laneWorkflowId) { + return "board.rejection.workflowMismatch"; + } + const workflow = boardWorkflows.workflows.find((w) => w.id === laneWorkflowId); + if (!workflow) return null; + const targetCol = workflow.columns.find((c) => c.id === targetColumnId); + if (!targetCol) return "board.rejection.unknownColumn"; + // Capacity pre-check: a wip-flagged column that is already full rejects. + if (targetCol.flags.countsTowardWip) { + const occupants = tasks.filter( + (t) => t.column === targetColumnId && (boardWorkflows.taskWorkflowIds[t.id] ?? boardWorkflows.defaultWorkflowId) === laneWorkflowId, + ).length; + // The default workflow's in-progress limit is maxConcurrent; custom limits + // are enforced authoritatively server-side (the 409 fallback still snaps back). + if (Number.isFinite(maxConcurrent) && maxConcurrent > 0 && sourceTask.column !== targetColumnId && occupants >= maxConcurrent) { + return "board.rejection.capacityExhausted"; + } + } + return null; + }, [boardWorkflows, tasks, maxConcurrent]); + // FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`, // `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated` // messages. We do NOT eagerly call `/api/github/batch-status` on board load. + if (flagOn) { + return ( +
{ + const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id"); + if (id) draggingTaskIdRef.current = id; + }} + onDragEnd={() => { + draggingTaskIdRef.current = null; + }} + > + {lanes.map(({ workflow, tasks: laneTasks }) => ( + + ))} +
+ ); + } + return ( <>
diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index abcd3af535..50d0f50c43 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -11,13 +11,80 @@ 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 } from "../api"; +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[]; @@ -77,16 +144,53 @@ interface ColumnProps { 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; + /** 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, blockerFanoutMap, prAuthAvailable }: ColumnProps) { +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, blockerFanoutMap, prAuthAvailable, workflowMode, 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(); @@ -110,34 +214,54 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, }; }, [isMenuOpen]); - // Archived column is collapsed by default - don't show drag state when collapsed - const isArchived = column === "archived"; + // 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 && column !== "in-progress" && tasks.length > PAGINATED_COLUMN_THRESHOLD; + const shouldPaginate = !isArchived && !isSearchActive && !isLegacyInProgress && tasks.length > PAGINATED_COLUMN_THRESHOLD; useEffect(() => { setVisibleTaskCount((current) => { - if (column === "in-progress" || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) { + if (isLegacyInProgress || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) { return VISIBLE_TASKS_INITIAL; } return Math.min(Math.max(current, VISIBLE_TASKS_INITIAL), tasks.length); }); - }, [column, isArchived, 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]); + }, [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); } }, []); @@ -185,14 +309,52 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, await onMoveTask(taskId, column, moveOptions); } catch (err) { - addToast(getErrorMessage(err), "error"); + // 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]); + }, [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 (column !== "in-progress") return []; + if (!isLegacyInProgress) return []; return groupByWorktree(tasks, tasks, maxConcurrent); - }, [column, tasks, maxConcurrent]); + }, [isLegacyInProgress, tasks, maxConcurrent]); const visibleTasks = useMemo(() => { if (!shouldPaginate) return tasks; @@ -238,7 +400,13 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, [tasks], ); const pauseEligibleCount = pauseEligibleTasks.length; - const hasColumnBulkActions = column === "todo" || column === "in-progress" || column === "in-review"; + // 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 handlePauseAll = useCallback(async () => { @@ -353,9 +521,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, >
-

{COLUMN_LABELS[column]}

+

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

{tasks.length} - {column === "in-review" && onToggleAutoMerge && ( + {(workflowMode ? isReviewColumn : column === "in-review") && onToggleAutoMerge && (