Files
fusion/packages/dashboard/app/components/Board.tsx
gsxdsm 2eae0b2507 feat: remove stuck-task tagging from the dashboard; fix liveness-ratchet scan path
Removes the dashboard's stuck-task tagging per operator request: the Stuck
card/status badges, stuck row styling, the footer Stuck segment and
stuckTaskCount stat, utils/taskStuck.ts, the isStuck agent-activity gate,
and the taskStuckTimeoutMs prop plumbing (App -> Board/Lane/Column/
WorktreeGroup/MainContent -> TaskCard/ListView/ExecutorStatusBar). Stuck-task
tests are deleted or reconciled. The taskStuckTimeoutMs setting and the
engine's recovery sweeps (including the stuck-killed status) are unchanged —
the setting is engine-side only now.

Also repoints the FN-6756 liveness-gate ratchet's facade scans at
executor/task-executor-session-facades.ts, where the wave20 extraction moved
hasLiveSessionSurface/clearPhantomExecutorBinding (the two pre-existing red
tests on main).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:47:43 -07:00

1207 lines
63 KiB
TypeScript

import { sortTasksForDisplayColumn, type DoneColumnSortMode, type Task, type TaskDetail, type Column as ColumnType, type ColumnId, type TaskCreateInput, type GithubIssueAction, type MergeResult } from "@fusion/core";
import { Column } from "./Column";
import { TaskCard } from "./TaskCard";
import "./Lane.css";
import "./Board.css";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn, type RevertTaskOptions, type RevertTaskResult } from "../api";
import { useBlockerFanout, type BlockerFanoutColumnFlags } from "../hooks/useBlockerFanout";
import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
import { WorkflowSwitcher } from "./WorkflowSwitcher";
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { useUnmappedWorkflowRefetch } from "../hooks/useUnmappedWorkflowRefetch";
import {
ALL_WORKFLOWS_BOARD_VIEW_ID,
readBoardWorkflowViewSelection,
removeBoardWorkflowSelection,
writeBoardWorkflowSelection,
} from "../utils/boardWorkflowSelection";
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
import { isTaskReverted, partitionRevertedTasks } from "../utils/taskRevert";
interface BoardProps {
tasks: Task[];
projectId?: string;
maxConcurrent: number;
showWorktreeGrouping: boolean;
onMoveTask: (id: string, column: ColumnId) => Promise<Task>;
onPauseTask?: (id: string) => Promise<Task>;
onUnpauseTask?: (id: string) => Promise<Task>;
onResetTask?: (id: string) => Promise<Task>;
onDuplicateTask?: (id: string) => Promise<Task>;
onMergeTask?: (id: string) => Promise<MergeResult>;
onOpenDetail: (task: Task | TaskDetail) => void;
onOpenRefine?: (task: Task | TaskDetail) => void;
onOpenGroupModal?: (groupId: string) => void;
addToast: (message: string, type?: ToastType) => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
onNewTask: (workflowId?: string | null) => void;
autoMerge: boolean;
/** Project merge strategy passed to Board-owned card context menus. */
mergeStrategy?: string;
onToggleAutoMerge: () => void;
planAutoApproveEnabled: boolean;
onTogglePlanAutoApprove: () => void;
globalPaused?: boolean;
onUpdateTask?: (
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
/* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask/onUnarchiveTask. */
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
/** Opens a New Task draft using a reverted task description. */
onReviseTask?: (task: Task) => void;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
}) => Promise<Task>;
onArchiveAllDone?: () => Promise<Task[]>;
/** Lazy-load archived tasks. Called the first time the user expands the archived column. */
onLoadArchivedTasks?: () => Promise<void>;
/** FNXC:ArchivePagination 2026-07-08-00:00: FN-7659 — fetch the next 100-item page of archived tasks (newest-first). Threaded to the Archived column's server-backed "Show more" button. */
onLoadMoreArchivedTasks?: () => Promise<void>;
/** Whether another archived page is available beyond what is currently loaded. */
archivedHasMore?: boolean;
/** True while a "Show more" archived page fetch is in flight. */
archivedLoadingMore?: boolean;
searchQuery?: string;
availableModels?: ModelInfo[];
/**
* Called when the user clicks the "Plan" button in the inline create card.
*/
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
/**
* Called when the user clicks the "Subtask" button in the inline create card.
*/
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void;
favoriteProviders?: string[];
favoriteModels?: string[];
onToggleFavorite?: (provider: string) => void;
onToggleModelFavorite?: (modelId: string) => void;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
/** Age threshold in milliseconds before high fan-out blockers escalate in dashboard surfaces. */
staleHighFanoutBlockerAgeThresholdMs?: number;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
prAuthAvailable?: boolean;
/** Opens the workflow editor modal, optionally focused on a workflow id. */
onOpenWorkflowEditor?: (workflowId?: string) => void;
/** Opens the workflow editor to create a new workflow. */
onCreateWorkflow?: () => void;
/** Already-resolved app setting for whether workflow lanes should be used. */
/** Relocates workflow controls into the Header portal slot when sidebar navigation owns the inline chrome. */
workflowControlsInHeader?: boolean;
}
let boardWasPreviouslyInactive = false;
// Real mobile browsers can pan the document horizontally while focusing/clicking
// an offscreen in-review auto-merge control. Keep that scroll container pinned;
// the board itself remains the only horizontal scroller.
function resetDocumentHorizontalScroll() {
const scrollingElement = document.scrollingElement as HTMLElement | null;
if (window.scrollX !== 0) {
window.scrollTo(0, window.scrollY);
}
if (scrollingElement) {
scrollingElement.scrollLeft = 0;
}
document.documentElement.scrollLeft = 0;
if (document.body) {
document.body.scrollLeft = 0;
}
}
function scheduleDocumentHorizontalScrollReset() {
const run = () => {
resetDocumentHorizontalScroll();
setTimeout(resetDocumentHorizontalScroll, 0);
};
if (typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(run);
return;
}
setTimeout(run, 0);
}
export { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
type AggregateBoardColumn = BoardWorkflowColumn & { sourceWorkflowIds: string[] };
type AggregateQuickCreateTarget = { columnId: string; workflowId: string };
function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
return (
<main className="board board-workflows-skeleton" id="board" aria-busy={!empty} aria-label={empty ? "No workflow lanes available" : "Loading workflow lanes"} data-testid={empty ? "board-workflows-empty" : "board-workflows-skeleton"}>
{[0, 1, 2].map((index) => (
<section className="board-workflows-skeleton__column card" key={index} aria-hidden="true">
<div className="board-workflows-skeleton__header" />
<div className="board-workflows-skeleton__card" />
<div className="board-workflows-skeleton__card board-workflows-skeleton__card--short" />
</section>
))}
</main>
);
}
/*
FNXC:WorkflowResolvedColumns 2026-07-30-00:30 (batch-dashboard-app):
Does this column offer "Archive all done"? The COMPLETE trait, matching the sibling spreads that
already resolve `intake` and `mergeBlocker`/`humanReview` from the same `columnDef.flags`. The id
check was the odd one out, so on a renamed board the action vanished from the completed column while
its neighbouring affordances rendered correctly — an inconsistency inside one props spread, which is
how it survived review.
`archived` is excluded: that lane is also complete-ish and must not offer to archive its own
contents. Hoisted to a named predicate because both render sites need it and a JSX attribute
position cannot carry the explanation.
*/
function columnDefOffersArchiveAllDone(columnDef: { flags: { complete?: boolean; archived?: boolean } }): boolean {
return columnDef.flags.complete === true && columnDef.flags.archived !== true;
}
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onReviseTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowControlsInHeader = false }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
/*
FNXC:DoneColumnSorting 2026-06-29-16:57:
Board owns one Done sort mode so legacy and built-in workflow Done surfaces stay in sync; the default remains completion-date descending to preserve existing first-load ordering.
*/
const [doneSortMode, setDoneSortMode] = useState<DoneColumnSortMode>("completion-date-desc");
const [isAllWorkflowsViewSelected, setIsAllWorkflowsViewSelected] = useState(
() => readBoardWorkflowViewSelection(projectId) === ALL_WORKFLOWS_BOARD_VIEW_ID,
);
const archivedLoadedRef = useRef(false);
const boardRef = useRef<HTMLElement | null>(null);
const [boardElement, setBoardElement] = useState<HTMLElement | null>(null);
/*
FNXC:BoardNavigation 2026-07-16-00:00:
The board can first render a workflow-loading skeleton, so a mutable ref alone would not
re-run the snap effect after the live board mounts. Mirror the callback ref in state to attach
user-only mobile snapping to every live Board variant without snapping the skeleton.
*/
const setBoardRef = useCallback((element: HTMLElement | null) => {
boardRef.current = element;
setBoardElement((current) => current === element ? current : element);
}, []);
useColumnScrollSnap(boardElement, { mobileOnly: true });
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
if (typeof document === "undefined") return null;
return document.getElementById("header-workflow-slot");
});
const viewportMode = useViewportMode();
// Normalized search-active signal: trimmed and non-empty
const isSearchActive = searchQuery.trim() !== "";
useEffect(() => {
if (!workflowControlsInHeader || typeof document === "undefined") {
setHeaderWorkflowSlot(null);
return;
}
setHeaderWorkflowSlot(document.getElementById("header-workflow-slot"));
}, [workflowControlsInHeader, viewportMode]);
useEffect(() => {
recordResumeEvent({
view: "Board",
trigger: boardWasPreviouslyInactive ? "route-active" : "remount",
projectId,
replayAttempted: false,
});
boardWasPreviouslyInactive = false;
return () => {
boardWasPreviouslyInactive = true;
recordResumeEvent({
view: "Board",
trigger: "route-inactive",
projectId,
replayAttempted: false,
});
};
}, [projectId]);
const handleToggleArchivedCollapse = useCallback(() => {
setArchivedCollapsed((current) => {
const next = !current;
if (!next && !archivedLoadedRef.current && onLoadArchivedTasks) {
archivedLoadedRef.current = true;
void onLoadArchivedTasks();
}
return next;
});
}, [onLoadArchivedTasks]);
/*
FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9, R8):
`tasksByColumn` and its stable-identity cache ref are DELETED with the legacy
single-lane board that was their only consumer. Both hardcoded the six legacy
column ids as object literals, so they could not have bucketed a workflow-defined
column at all. The workflow board buckets from each lane's own column ids
(`selectedWorkflowTasks` / `aggregateBoardColumns`) and keeps its own memoization.
*/
/*
FNXC:BoardNavigation 2026-06-30-17:42:
Periodic task/workflow refreshes, rerenders, window resize, and visualViewport resize must not override intentional board-column scroll while the Board is already visible. Keep FN-001/FN-4574 stabilization focused on page-level horizontal drift and layout reflow; #board is the user's horizontal scroller, so it must not be forced back to triage.
*/
// FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally
// snap against stale layout/visualViewport metrics before flex columns resolve,
// both on initial mount and on pageshow/bfcache restore after backgrounding.
// We keep the FN-001 baseline (`scroll-snap-type: x proximity` +
// `overflow-anchor: none`) and only stabilize via reflow + document scroll
// normalization; do NOT reintroduce `scroll-snap-type: x mandatory`.
useEffect(() => {
const mobileQuery = window.matchMedia(MOBILE_MEDIA_QUERY);
let rafId: number | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const runStabilization = () => {
const boardEl = boardRef.current;
if (!boardEl) return;
void boardEl.offsetWidth;
if (mobileQuery.matches) {
resetDocumentHorizontalScroll();
}
};
const scheduleStabilization = () => {
if (typeof window.requestAnimationFrame === "function") {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(() => {
rafId = null;
runStabilization();
});
return;
}
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
timeoutId = null;
runStabilization();
}, 0);
};
const handlePageShow = (event: PageTransitionEvent) => {
const viewportScale = window.visualViewport?.scale ?? 1;
if (event.persisted || viewportScale > 1.0001) {
scheduleStabilization();
}
};
const visualViewport = window.visualViewport;
const handleViewportResize = () => {
scheduleStabilization();
};
const addChangeListener = (query: MediaQueryList, listener: () => void) => {
if (typeof query.addEventListener === "function") {
query.addEventListener("change", listener);
return;
}
if (typeof query.addListener === "function") {
query.addListener(listener);
}
};
const removeChangeListener = (query: MediaQueryList, listener: () => void) => {
if (typeof query.removeEventListener === "function") {
query.removeEventListener("change", listener);
return;
}
if (typeof query.removeListener === "function") {
query.removeListener(listener);
}
};
scheduleStabilization();
window.addEventListener("pageshow", handlePageShow);
window.addEventListener("resize", handleViewportResize);
addChangeListener(mobileQuery, handleViewportResize);
if (typeof visualViewport?.addEventListener === "function") {
visualViewport.addEventListener("resize", handleViewportResize);
}
return () => {
window.removeEventListener("pageshow", handlePageShow);
window.removeEventListener("resize", handleViewportResize);
removeChangeListener(mobileQuery, handleViewportResize);
if (typeof visualViewport?.removeEventListener === "function") {
visualViewport.removeEventListener("resize", handleViewportResize);
}
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
};
}, []);
// ── U9 multi-lane board ───────────────────────────────────────────────────
/*
FNXC:BoardWorkflows 2026-06-20-08:58:
Operators must never see a partial board while board-workflows metadata is still loading. Hydrate metadata from the project-scoped session cache, reset it on project switches, and show a neutral skeleton while uncached workflow metadata is unknown.
FNXC:Workflows 2026-06-22-17:00:
The board-workflows fetch/cache/SSE/selection loop lives in `useBoardWorkflows`, shared verbatim with the Planning header slot, and Board consumes the exposed raw state setter for optimistic task→workflow assignment.
FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9):
The `shouldHydrateCache` gate is DELETED. It read `workflowColumnsEnabled === true || settingsLoaded === false`, and every call site passed `workflowColumnsEnabled` as a literal `true` (`MainContent`), so the expression was unconditionally true — identical to the hook's default. Both props are gone with it; Board no longer needs settings loaded at all to decide what to render.
*/
const {
boardWorkflows,
workflowMode,
workflowOptions,
selectedWorkflow,
selectedWorkflowId,
setSelectedWorkflowId,
refreshBoardWorkflows,
setBoardWorkflowsState,
} = useBoardWorkflows({ projectId });
const draggingTaskIdRef = useRef<string | null>(null);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-23:15 (the board's fan-out read the LEGACY lanes):
Resolve each card's traits through its OWN workflow — the construction `App.tsx` already uses for
the footer index — so the fan-out map classifies against the operator's column names. Without it
core fell back to `todo`/`in-review`/`done`, and since "active" is defined by exclusion, a finished
card in a renamed completion lane stayed an active blocker forever.
*/
const blockerFanoutColumnFlagsByTaskId = useMemo(() => {
const index = new Map<string, BlockerFanoutColumnFlags>();
if (!boardWorkflows) return index;
const workflowsById = new Map(boardWorkflows.workflows.map((workflow) => [workflow.id, workflow]));
for (const task of tasks) {
const workflow = workflowsById.get(boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId);
const flags = workflow?.columns.find((column) => column.id === task.column)?.flags;
if (flags) index.set(task.id, flags);
}
return index;
}, [boardWorkflows, tasks]);
const blockerFanoutMap = useBlockerFanout(tasks, {
staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs,
columnFlagsByTaskId: blockerFanoutColumnFlagsByTaskId,
});
const handlePromote = useCallback(async (taskId: string, options?: { force?: boolean }) => {
// `force` only ever arrives from Column's confirmed unplanned-for-execution override.
await promoteTask(taskId, projectId, options);
}, [projectId]);
const handleToggleAutoMerge = useCallback(() => {
onToggleAutoMerge();
if (window.matchMedia(MOBILE_MEDIA_QUERY).matches) {
scheduleDocumentHorizontalScrollReset();
}
}, [onToggleAutoMerge]);
const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []);
const workflowStatusCounts = useMemo(() => {
/*
FNXC:WorkflowSwitcher 2026-07-01-23:04:
computeWorkflowStatusCounts already owns the dashboard-only All workflows aggregate sentinel. Board must pass the helper result through directly instead of re-summing every map entry, because the map includes the sentinel and summing it again doubles the dropdown aggregate row.
*/
return computeWorkflowStatusCounts(tasks, boardWorkflows);
}, [boardWorkflows, tasks]);
useEffect(() => {
setIsAllWorkflowsViewSelected(readBoardWorkflowViewSelection(projectId) === ALL_WORKFLOWS_BOARD_VIEW_ID);
}, [projectId]);
const handleWorkflowSwitcherChange = useCallback((workflowId: string) => {
/*
FNXC:WorkflowBoard 2026-06-30-00:00:
"All workflows" is a Board-only aggregate filter sentinel. It is now persisted in the same project-scoped Board view preference as real workflow ids so refresh/remount restores whichever Board view the operator last selected, while `useBoardWorkflows` filters the sentinel away from shared real-workflow consumers and backend-bound APIs.
*/
if (workflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) {
setIsAllWorkflowsViewSelected(true);
writeBoardWorkflowSelection(projectId, ALL_WORKFLOWS_BOARD_VIEW_ID);
return;
}
setIsAllWorkflowsViewSelected(false);
setSelectedWorkflowId(workflowId);
}, [projectId, setSelectedWorkflowId]);
useEffect(() => {
if (boardWorkflows && !workflowMode) {
setIsAllWorkflowsViewSelected(false);
if (readBoardWorkflowViewSelection(projectId) === ALL_WORKFLOWS_BOARD_VIEW_ID) {
removeBoardWorkflowSelection(projectId);
}
}
}, [boardWorkflows, projectId, workflowMode]);
const knownWorkflowIds = useMemo(() => new Set(boardWorkflows?.workflows.map((workflow) => workflow.id) ?? []), [boardWorkflows]);
const workflowColumnsByWorkflowId = useMemo(() => {
const byWorkflow = new Map<string, Map<string, BoardWorkflowColumn>>();
for (const workflow of boardWorkflows?.workflows ?? []) {
byWorkflow.set(workflow.id, new Map(workflow.columns.map((column) => [column.id, column])));
}
return byWorkflow;
}, [boardWorkflows]);
const getEffectiveTaskWorkflowId = useCallback((task: Task) => {
if (!boardWorkflows) return null;
const assignedWorkflowId = boardWorkflows.taskWorkflowIds[task.id];
return assignedWorkflowId && knownWorkflowIds.has(assignedWorkflowId)
? assignedWorkflowId
: boardWorkflows.defaultWorkflowId;
}, [boardWorkflows, knownWorkflowIds]);
/*
FNXC:WorkflowBoard 2026-07-05-14:20:
Invariant: every rendered task must resolve to its REAL workflow, or the board silently drops it.
A task created into a workflow whose intake column differs from the default (e.g. Coding (Ideas) → "ideas", per FN-7591) disappears until the next mount/focus/workflow-CRUD refetch. Cause: the task list (SSE) updates before the board-workflows `taskWorkflowIds` map, so getEffectiveTaskWorkflowId falls back to `defaultWorkflowId` (plain Coding), whose columns do not declare the intake column; the aggregate grouping then `continue`-skips the card and the single-workflow grouping files it into a never-rendered phantom bucket. The board's own quick-create handlers dodge this via applyOptimisticTaskWorkflow, but the shared create surfaces (QuickEntryBox / NewTaskModal / InlineCreateCard→TodoView / insight→task) route through useTaskHandlers and never seed the map. Fix at the invariant, not the create surface: whenever a rendered task is absent from taskWorkflowIds, force ONE board-workflows refetch so its persisted workflow selection (and intake column) resolves. Signature-guarded on the sorted unmapped-id set so we never spin an infinite refetch loop, and only run in workflow mode once the payload has loaded.
The refetch is deferred by one macrotask and re-checked against the latest state at fire time: the board's own quick-create commits the new task one microtask before applyOptimisticTaskWorkflow seeds it, so a synchronous refetch here would double-fire alongside the optimistic path. Deferring lets the seed land first — an already-mapped task is then skipped — so this only fetches for tasks that truly arrived without a workflow mapping.
*/
useUnmappedWorkflowRefetch({ boardWorkflows, tasks, workflowMode, refreshBoardWorkflows, projectId });
const resolveWorkflowQuickCreateTarget = useCallback((targetWorkflowId: string, preferredColumnId?: string | null): ColumnId | undefined => {
if (targetWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) return undefined;
const workflow = boardWorkflows?.workflows.find((candidate) => candidate.id === targetWorkflowId);
if (!workflow) return undefined;
const visibleColumns = workflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
const preferredColumn = preferredColumnId ? visibleColumns.find((column) => column.id === preferredColumnId) : undefined;
const column = preferredColumn
?? visibleColumns.find((candidate) => candidate.flags.intake)
?? visibleColumns[0];
return column?.id as ColumnId | undefined;
}, [boardWorkflows]);
const selectedWorkflowTasks = useMemo(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return [];
return tasks.filter((task) => getEffectiveTaskWorkflowId(task) === selectedWorkflow.id);
}, [boardWorkflows, getEffectiveTaskWorkflowId, selectedWorkflow, tasks, workflowMode]);
const applyOptimisticTaskWorkflow = useCallback((taskId: string, workflowId: string) => {
setBoardWorkflowsState((previous) => {
if (!previous || previous.projectId !== projectId) return previous;
if (previous.payload.taskWorkflowIds[taskId]) return previous;
const payload: BoardWorkflowsPayload = {
...previous.payload,
taskWorkflowIds: {
...previous.payload.taskWorkflowIds,
[taskId]: workflowId,
},
};
writeBoardWorkflowsCache(projectId, payload);
return { projectId, payload };
});
}, [projectId]);
/**
* FNXC:WorkflowBoard 2026-06-21-21:34:
* A task created on a selected non-default workflow lane must render in that lane immediately. The task list updates before board-workflows taskWorkflowIds, so without this optimistic project-scoped assignment the filter falls back to the default workflow and hides the new card until the next metadata refetch (FN-6903).
*/
const handleWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => {
if (!onQuickCreate || !selectedWorkflow) return undefined;
const targetWorkflowId = typeof input.workflowId === "string" && input.workflowId !== ALL_WORKFLOWS_BOARD_VIEW_ID
? input.workflowId
: selectedWorkflow.id;
const targetColumn = resolveWorkflowQuickCreateTarget(targetWorkflowId, input.column);
const created = await onQuickCreate({
...input,
...(targetColumn ? { column: targetColumn } : {}),
workflowId: targetWorkflowId,
});
if (created?.id) {
const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? targetWorkflowId;
applyOptimisticTaskWorkflow(created.id, createdWorkflowId);
refreshBoardWorkflows();
}
return created;
}, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows, resolveWorkflowQuickCreateTarget, selectedWorkflow]);
/**
* FNXC:WorkflowBoard 2026-06-29-23:58:
* The aggregate All workflows board is a read-side union, not a real workflow. Quick create must attach to one real workflow intake/default column so custom-default projects never submit synthetic `triage` or an empty workflow id to the backend.
*/
const handleAggregateWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => {
if (!onQuickCreate) return undefined;
const targetWorkflowId = typeof input.workflowId === "string" && input.workflowId !== ALL_WORKFLOWS_BOARD_VIEW_ID
? input.workflowId
: (boardWorkflows?.workflows.find((workflow) => workflow.id === boardWorkflows.defaultWorkflowId)?.id ?? boardWorkflows?.workflows[0]?.id);
const targetColumn = targetWorkflowId ? resolveWorkflowQuickCreateTarget(targetWorkflowId, input.column) : undefined;
const created = await onQuickCreate({
...input,
...(targetColumn ? { column: targetColumn } : {}),
...(targetWorkflowId ? { workflowId: targetWorkflowId } : {}),
});
if (created?.id && targetWorkflowId) {
const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? targetWorkflowId;
applyOptimisticTaskWorkflow(created.id, createdWorkflowId);
refreshBoardWorkflows();
}
return created;
}, [applyOptimisticTaskWorkflow, boardWorkflows, onQuickCreate, refreshBoardWorkflows, resolveWorkflowQuickCreateTarget]);
const selectedWorkflowArchivedColumn = useMemo(() => {
if (!selectedWorkflow) return null;
return selectedWorkflow.columns.find((column) => column.flags.archived) ?? null;
}, [selectedWorkflow]);
const selectedWorkflowColumns = useMemo(() => {
if (!selectedWorkflow) return [];
return selectedWorkflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
}, [selectedWorkflow]);
const selectedWorkflowCreateColumnId = useMemo(() => {
return selectedWorkflowColumns.find((column) => column.flags.intake && !column.flags.archived)?.id
?? selectedWorkflowColumns.find((column) => !column.flags.archived)?.id;
}, [selectedWorkflowColumns]);
const handleSelectedWorkflowNewTask = useCallback(() => {
onNewTask(selectedWorkflow?.id);
}, [onNewTask, selectedWorkflow?.id]);
const workflowContextMenuColumnsByWorkflowId = useMemo(() => {
const map = new Map<string, readonly TaskContextMenuColumnMetadata[]>();
for (const workflow of boardWorkflows?.workflows ?? []) {
map.set(workflow.id, workflow.columns
.filter((column) => !column.flags.hiddenFromBoard)
.map((column) => ({ id: column.id, label: column.name, flags: column.flags, ...(column.moveTargets ? { moveTargets: column.moveTargets } : {}) })));
}
return map;
}, [boardWorkflows]);
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
The TASK IDS whose own column is a hold lane IN THEIR OWN WORKFLOW.
Resolved per task rather than as a board-wide set of hold column IDS (PR #2625 review —
greptile). Column ids are namespaced per workflow, so two workflows can both declare
`staging` with one marking it `hold` and the other `countsTowardWip`. A unioned id set
cannot tell those apart, and every executing card in the second workflow would have shown
up under Up Next as waiting work — a wrong answer presented confidently, which is worse
than the renamed-board emptiness this change set out to fix.
Resolving through `getEffectiveTaskWorkflowId` removes the ambiguity instead of narrowing
it: the question "is this card waiting?" is answered by the card's own workflow, which is
the only workflow that can answer it.
*/
const holdTaskIds = useMemo(() => {
const holdColumnsByWorkflowId = new Map<string, Set<string>>();
for (const workflow of boardWorkflows?.workflows ?? []) {
holdColumnsByWorkflowId.set(
workflow.id,
new Set(workflow.columns.filter((col) => col.flags.hold === true).map((col) => col.id)),
);
}
const ids = new Set<string>();
for (const task of tasks) {
const workflowId = getEffectiveTaskWorkflowId(task);
if (workflowId && holdColumnsByWorkflowId.get(workflowId)?.has(task.column)) ids.add(task.id);
}
return ids;
}, [boardWorkflows, tasks, getEffectiveTaskWorkflowId]);
const selectedWorkflowContextMenuColumns = useMemo(() => (
selectedWorkflow ? workflowContextMenuColumnsByWorkflowId.get(selectedWorkflow.id) : undefined
), [selectedWorkflow, workflowContextMenuColumnsByWorkflowId]);
const taskContextMenuColumnsByTaskId = useMemo(() => {
const map = new Map<string, readonly TaskContextMenuColumnMetadata[]>();
if (!workflowMode || !boardWorkflows) return map;
for (const task of tasks) {
const workflowId = getEffectiveTaskWorkflowId(task);
const columns = workflowId ? workflowContextMenuColumnsByWorkflowId.get(workflowId) : undefined;
if (columns) map.set(task.id, columns);
}
return map;
}, [boardWorkflows, getEffectiveTaskWorkflowId, tasks, workflowContextMenuColumnsByWorkflowId, workflowMode]);
const selectedWorkflowTasksByColumn = useMemo(() => {
const grouped: Record<string, Task[]> = {};
if (!selectedWorkflow) return grouped;
for (const column of selectedWorkflow.columns) grouped[column.id] = [];
/*
FNXC:WorkflowBoard 2026-07-05-14:20:
Safety net (defense in depth for the taskWorkflowIds refetch above): a card that passed the selected-workflow membership filter genuinely belongs on THIS board, so it must always land in a rendered lane. If its stored `column` is not one this workflow declares (a workflow edited to drop a column, or a create/refetch race that lands an intake-column card before its lane is known), re-home it for DISPLAY into the workflow's intake/first visible column instead of a `??=`-created bucket that is never rendered. Display-only — the task's stored column is untouched.
*/
for (const task of selectedWorkflowTasks) {
if (isTaskReverted(task.sourceMetadata) && selectedWorkflow.columns.find((column) => column.id === task.column)?.flags.complete) continue;
const columnId = grouped[task.column] !== undefined
? task.column
: (selectedWorkflowCreateColumnId ?? task.column);
(grouped[columnId] ??= []).push(task);
}
for (const column of selectedWorkflow.columns) {
grouped[column.id] = sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id, {
columnFlags: column.flags,
doneSortMode,
});
}
return grouped;
}, [doneSortMode, selectedWorkflow, selectedWorkflowCreateColumnId, selectedWorkflowTasks]);
// Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes
// when the board-workflows payload changes, not on every SSE task tick.
const cardDefsByWorkflow = useMemo(() => {
const map = new Map<string, import("../api").WorkflowFieldDefinition[]>();
if (!boardWorkflows) return map;
for (const wf of boardWorkflows.workflows) {
const cardDefs = (wf.fields ?? []).filter((f) => f.render?.placement === "card");
if (cardDefs.length > 0) map.set(wf.id, cardDefs);
}
return map;
}, [boardWorkflows]);
// Per-task card field defs (U13/KTD-14). Recomputes on task list changes but
// reuses the stable cardDefsByWorkflow map so the inner loop is cheap.
const taskCardFieldDefs = useMemo(() => {
const map = new Map<string, import("../api").WorkflowFieldDefinition[]>();
if (cardDefsByWorkflow.size === 0) return map;
if (!boardWorkflows) return map;
for (const task of tasks) {
const workflowId = getEffectiveTaskWorkflowId(task);
const defs = workflowId ? cardDefsByWorkflow.get(workflowId) : undefined;
if (defs) map.set(task.id, defs);
}
return map;
}, [cardDefsByWorkflow, getEffectiveTaskWorkflowId, tasks, boardWorkflows]);
const workflowIdentityById = useMemo(() => {
const map = new Map<string, { workflowName: string; workflowIcon?: string }>();
if (!boardWorkflows) return map;
for (const workflow of boardWorkflows.workflows) {
map.set(workflow.id, { workflowName: workflow.name, workflowIcon: workflow.icon });
}
return map;
}, [boardWorkflows]);
/*
FNXC:WorkflowBoard 2026-06-29-00:00:
All-workflows Board cards need trustworthy workflow-name badges, but per-workflow Board views and other TaskCard callers must not render empty shells. Derive badges only from board-workflows metadata, falling stale or missing task assignments back to the default workflow without persisting the aggregate sentinel.
*/
const aggregateTaskWorkflowBadges = useMemo(() => {
const map = new Map<string, { workflowId: string; workflowName: string; workflowIcon?: string }>();
if (!boardWorkflows) return map;
for (const task of tasks) {
const assignedWorkflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
const workflowId = workflowIdentityById.has(assignedWorkflowId) ? assignedWorkflowId : boardWorkflows.defaultWorkflowId;
const workflowIdentity = workflowIdentityById.get(workflowId);
if (workflowIdentity) {
map.set(task.id, { workflowId, ...workflowIdentity });
}
}
return map;
}, [boardWorkflows, tasks, workflowIdentityById]);
/*
FNXC:WorkflowBoard 2026-06-29-16:00:
The aggregate Board view must not hide cards from custom workflow columns. Build a non-persisted union of visible workflow column ids and append canonical lifecycle columns so all task columns have a rendered destination without inventing a backend workflow id.
FNXC:WorkflowBoard 2026-06-29-18:37:
Shared aggregate column ids must use the default workflow's label and trait flags when that workflow declares them; otherwise preserve the first workflow definition that introduced the id. This keeps "All workflows" deterministic for duplicate column names without OR-merging incompatible workflow traits.
FNXC:WorkflowBoard 2026-06-29-23:54:
Aggregate Board rendering separates active columns from archived columns after the deterministic union is built. This preserves the existing collapsed archived-column behavior while the main All workflows lane set stays limited to non-hidden, non-archived destinations.
FNXC:WorkflowResolvedColumns 2026-07-27-14:35 (U10 / R8):
The union is now built ONLY from the workflows the payload declares. It previously appended every id of the legacy `COLUMNS` enum with synthesised trait flags, which drew a phantom lane for any lifecycle column no workflow declares — the visible failure a removed column (U11 merging Todo into Planning) would ship. Those injected lanes also carried the raw column id as their label, so a lane named "in-progress" appeared beside properly named workflow lanes.
Ordering follows the workflows' own declaration order (default workflow first, then the remaining workflows in payload order) instead of the legacy enum's index. For the built-in workflows the two are identical — their IR declares columns in exactly the legacy order — so the default board is unchanged; for a renamed or reordered workflow the lanes now follow the IR rather than collapsing to an alphabetical tie-break.
Cards resting in a column NO workflow declares are still rendered: `aggregateTasksByColumn` re-homes them for display into the aggregate quick-create intake lane (see its safety net below). Dropping the injected lanes therefore removes phantom lanes without stranding a single card.
*/
const aggregateBoardColumns = useMemo<AggregateBoardColumn[]>(() => {
const byId = new Map<string, AggregateBoardColumn>();
if (boardWorkflows) {
const defaultWorkflow = boardWorkflows.workflows.find((workflow) => workflow.id === boardWorkflows.defaultWorkflowId);
const orderedWorkflows = [
...(defaultWorkflow ? [defaultWorkflow] : []),
...boardWorkflows.workflows.filter((workflow) => workflow.id !== boardWorkflows.defaultWorkflowId),
];
for (const workflow of orderedWorkflows) {
for (const column of workflow.columns) {
if (column.flags.hiddenFromBoard) continue;
const existing = byId.get(column.id);
if (existing) {
existing.sourceWorkflowIds.push(workflow.id);
continue;
}
byId.set(column.id, { ...column, flags: { ...column.flags }, sourceWorkflowIds: [workflow.id] });
}
}
}
return [...byId.values()];
}, [boardWorkflows]);
const aggregateQuickCreateTarget = useMemo<AggregateQuickCreateTarget | null>(() => {
if (!boardWorkflows) return null;
const defaultWorkflow = boardWorkflows.workflows.find((workflow) => workflow.id === boardWorkflows.defaultWorkflowId);
const orderedWorkflows = [
...(defaultWorkflow ? [defaultWorkflow] : []),
...boardWorkflows.workflows.filter((workflow) => workflow.id !== boardWorkflows.defaultWorkflowId),
];
for (const workflow of orderedWorkflows) {
const column = workflow.columns.find((candidate) => candidate.flags.intake && !candidate.flags.archived && !candidate.flags.hiddenFromBoard)
?? workflow.columns.find((candidate) => !candidate.flags.archived && !candidate.flags.hiddenFromBoard);
if (column) return { columnId: column.id, workflowId: workflow.id };
}
return null;
}, [boardWorkflows]);
const handleAggregateWorkflowNewTask = useCallback(() => {
onNewTask(aggregateQuickCreateTarget?.workflowId);
}, [aggregateQuickCreateTarget?.workflowId, onNewTask]);
const aggregateVisibleBoardColumns = useMemo(
() => aggregateBoardColumns.filter((column) => column.flags.archived !== true),
[aggregateBoardColumns],
);
const aggregateArchivedBoardColumns = useMemo(
() => aggregateBoardColumns.filter((column) => column.flags.archived === true),
[aggregateBoardColumns],
);
const aggregateRenderedBoardColumns = useMemo(
() => [...aggregateVisibleBoardColumns, ...aggregateArchivedBoardColumns],
[aggregateArchivedBoardColumns, aggregateVisibleBoardColumns],
);
/*
FNXC:TaskRevert 2026-08-01-20:06:
A successful revert is resolution-required work, not completed work. Aggregate the
shared, deduplicated partition once so All Workflows keeps those cards discoverable
after its complete lanes exclude them, including custom complete columns.
*/
const aggregateRevertedTasks = useMemo(
() => partitionRevertedTasks(tasks).reverted,
[tasks],
);
const aggregateTasksByColumn = useMemo(() => {
const grouped: Record<string, Task[]> = {};
for (const column of aggregateBoardColumns) grouped[column.id] = [];
// Column ids some workflow explicitly hides from the board. A column-orphaned
// task resting in one of these must stay hidden even when its (mis-)resolved
// workflow doesn't declare the column, or the fallback below would surface an
// explicitly hidden card in a visible lane.
const hiddenAnywhereColumnIds = new Set<string>();
for (const workflow of boardWorkflows?.workflows ?? []) {
for (const column of workflow.columns) {
if (column.flags.hiddenFromBoard) hiddenAnywhereColumnIds.add(column.id);
}
}
for (const task of tasks) {
const workflowId = getEffectiveTaskWorkflowId(task);
const workflowColumn = workflowId ? workflowColumnsByWorkflowId.get(workflowId)?.get(task.column) : null;
/*
FNXC:WorkflowBoard 2026-06-29-23:59:
Aggregate Board grouping must resolve the task's effective workflow before using a shared column id. If one workflow hides `qa` while another shows it, tasks assigned to the hidden `qa` column stay hidden instead of leaking into the visible aggregate lane.
*/
if (workflowColumn?.flags.hiddenFromBoard) continue;
if (isTaskReverted(task.sourceMetadata) && workflowColumn?.flags.complete) continue;
if (!workflowColumn) {
/*
FNXC:WorkflowBoard 2026-07-12-23:35:
Safety net (aggregate twin of the selected-workflow display re-home below/above): a task
whose resolved workflow does NOT declare its stored column must not be continue-dropped
into invisibility. This happens when a stale/missing task_workflow_selection resolves the
task to the default workflow (e.g. an "ideas" card resolving to plain Coding), or when an
engine rebound parks a card in a legacy column its workflow never declared. Render the card
in its stored column when the aggregate union declares that lane; otherwise re-home it for
DISPLAY into the aggregate quick-create intake lane. Display-only — the stored column is
untouched.
FNXC:WorkflowBoard 2026-07-13-11:55:
Two carve-outs keep the safety net honest: a stored column that ANY workflow declares
hiddenFromBoard stays hidden (the guard above can't see the true workflow's flag when the
mapping is stale), and when no rendered fallback lane exists (no quick-create target) the
card is skipped rather than pushed into a `grouped` key the render loop never reads.
*/
const laneExists = grouped[task.column] !== undefined;
if (!laneExists && hiddenAnywhereColumnIds.has(task.column)) continue;
const fallbackColumnId = laneExists ? task.column : aggregateQuickCreateTarget?.columnId;
if (fallbackColumnId === undefined || grouped[fallbackColumnId] === undefined) continue;
grouped[fallbackColumnId].push(task);
continue;
}
(grouped[task.column] ??= []).push(task);
}
for (const column of aggregateBoardColumns) {
grouped[column.id] = sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id, {
columnFlags: column.flags,
doneSortMode,
});
}
return grouped;
}, [aggregateBoardColumns, aggregateQuickCreateTarget, boardWorkflows, doneSortMode, getEffectiveTaskWorkflowId, tasks, workflowColumnsByWorkflowId]);
// 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 => (
getBoardCanDropTaskRejection({
boardWorkflows,
tasks,
maxConcurrent,
taskId,
targetColumnId,
laneWorkflowId,
})
), [boardWorkflows, tasks, maxConcurrent]);
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (U12 — measured perf fix):
Bind `canDropTask` per (lane, column) ONCE per dependency change instead of creating
a new arrow inline in the render. `Column` is `React.memo`, and a fresh function
identity on any prop defeats that entirely — so before this, ANY Board state change
(collapsing the archived column, changing Done sort, opening the switcher)
re-rendered EVERY column and every card beneath it, not just the affected one.
This was invisible for a long time: the "keeps unaffected columns stable" regression
test measured the LEGACY single-lane board, whose props were all stable. Deleting
that board (U12 part 1) repointed the test at the real board, where it failed. I
instrumented `React.memo`'s comparator to list which props actually change identity
on a collapse toggle, and the answer was exactly one: `canDropTask`.
A `useRef` cache invalidated by `useEffect` does NOT work here, which is why my first
attempt at this failed and was reverted: the effect runs AFTER the render that
populated the cache, so it wipes the very bindings that render created and the next
render allocates fresh ones. `useMemo` keyed on the resolver has no such window —
the map lives exactly as long as the closure it belongs to.
*/
const canDropTaskBinder = useMemo(() => {
const bindings = new Map<string, (taskId: string) => string | null>();
return (columnId: string, laneWorkflowId: string) => {
const key = `${laneWorkflowId}::${columnId}`;
let bound = bindings.get(key);
if (!bound) {
bound = (taskId: string) => canDropTask(taskId, columnId, laneWorkflowId);
bindings.set(key, bound);
}
return bound;
};
}, [canDropTask]);
// 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.
/*
FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9):
Show the skeleton until lanes resolve. Behaviour is unchanged, only the spelling:
the null arm was `workflowColumnsEnabled === true || settingsLoaded === false`
(always true — MainContent passed the literal `true`), and the loaded arm's
`flagEnabled === true` conjunct was a server constant. `empty` distinguishes
"still loading" (no payload) from "loaded, but this project resolved no lane",
which is what the former `flagEnabled` read was standing in for.
Note the retained failure behaviour: a board-workflows fetch that never succeeds
leaves `boardWorkflows === null` and holds the skeleton. That was already true
before this deletion — the legacy board below was NOT the fetch-failure fallback.
*/
if (boardWorkflows === null || boardWorkflows.workflows.length === 0) {
return <BoardWorkflowSkeleton empty={boardWorkflows !== null} />;
}
if (workflowMode && selectedWorkflow) {
const shouldRenderWorkflowControls = workflowOptions.length > 0;
const workflowSwitcherValue = isAllWorkflowsViewSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : (selectedWorkflowId ?? selectedWorkflow.id);
const workflowToolbar = shouldRenderWorkflowControls ? (
<div className="board-workflow-toolbar">
<div className="board-workflow-selector">
<WorkflowSwitcher
workflows={workflowOptions}
value={workflowSwitcherValue}
onChange={handleWorkflowSwitcherChange}
counts={workflowStatusCounts}
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
onOpen={refreshBoardWorkflows}
onEditWorkflow={onOpenWorkflowEditor}
onCreateWorkflow={onCreateWorkflow}
/>
</div>
</div>
) : null;
/*
FNXC:WorkflowControls 2026-06-20-00:00:
Board owns workflow selection state, so the existing selector/edit/create toolbar is portaled to Header only when the left sidebar is the active tablet/desktop navigation surface. If the Header slot is not mounted yet, render inline as the safe fallback so controls are never lost.
FNXC:WorkflowControls 2026-06-20-15:42:
Standalone workflow edit/create icon buttons were removed because those actions now live inside WorkflowSwitcher; keep this wrapper only when it contains the switcher to avoid empty toolbar shells.
*/
const relocatedWorkflowToolbar = workflowControlsInHeader && headerWorkflowSlot && workflowToolbar
? createPortal(workflowToolbar, headerWorkflowSlot)
: null;
const renderedWorkflowToolbar = workflowControlsInHeader && headerWorkflowSlot ? relocatedWorkflowToolbar : workflowToolbar;
if (isAllWorkflowsViewSelected) {
return (
<div className="board-workflow-view">
{renderedWorkflowToolbar}
<main className="board board-workflow-columns" id="board" ref={setBoardRef}>
{aggregateRenderedBoardColumns.map((columnDef) => {
const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id;
const isDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
return (
<Column
key={columnDef.id}
column={columnDef.id as ColumnType}
workflowMode
columnDisplayName={columnDef.name}
columnDescription={columnDef.description}
columnFlags={columnDef.flags}
holdTaskIds={holdTaskIds}
taskContextMenuColumnsByTaskId={taskContextMenuColumnsByTaskId}
tasks={aggregateTasksByColumn[columnDef.id] ?? []}
projectId={projectId}
maxConcurrent={maxConcurrent}
showWorktreeGrouping={showWorktreeGrouping}
onMoveTask={onMoveTask}
onPauseTask={onPauseTask}
onUnpauseTask={onUnpauseTask}
onResetTask={onResetTask}
onDuplicateTask={onDuplicateTask}
onMergeTask={onMergeTask}
onOpenDetail={onOpenDetail}
onPlanningMode={onPlanningMode}
onOpenRefine={onOpenRefine}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
allTasks={tasks}
availableModels={availableModels}
onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
taskCardFieldDefs={taskCardFieldDefs}
taskWorkflowBadges={aggregateTaskWorkflowBadges}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
mergeStrategy={mergeStrategy}
// FNXC:PlanApproval 2026-07-07-00:00: FN-7653 — the plan auto-approve shortcut belongs only to the intake/planning column, never to hold (Todo-like) columns; the built-in Coding workflow's Todo column carries the hold trait and was wrongly receiving this prop pair.
{...((columnDef.flags.intake && !columnDef.flags.archived && !columnDef.flags.complete && !columnDef.flags.countsTowardWip && !columnDef.flags.mergeBlocker && !columnDef.flags.humanReview) ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
{...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, workflowOptions, defaultWorkflowId: boardWorkflows?.defaultWorkflowId ?? null, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask: handleAggregateWorkflowNewTask, onSubtaskBreakdown } : {})}
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(columnDefOffersArchiveAllDone(columnDef) ? { onArchiveAllDone } : {})}
{...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
{...(columnDef.flags.archived ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived: onLoadMoreArchivedTasks } : {})}
/>
);
})}
{aggregateRevertedTasks.length > 0 && (
<section className="reverted-tasks-section" aria-label="Reverted Tasks" data-testid="board-reverted-tasks">
<h2>Reverted Tasks</h2>
{aggregateRevertedTasks.map((task) => (
<TaskCard
key={`reverted-${task.id}`}
task={task}
taskColumnFlags={blockerFanoutColumnFlagsByTaskId.get(task.id)}
onOpenDetail={onOpenDetail}
onDeleteTask={onDeleteTask}
onReviseTask={onReviseTask}
addToast={addToast}
disableDrag
/>
))}
</section>
)}
</main>
</div>
);
}
return (
<div className="board-workflow-view">
{renderedWorkflowToolbar}
<main
className="board board-workflow-columns"
id="board"
ref={setBoardRef}
onDragStart={(e) => {
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
if (id) draggingTaskIdRef.current = id;
}}
onDragEnd={() => {
draggingTaskIdRef.current = null;
}}
>
{selectedWorkflowColumns.map((columnDef) => {
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
const isWorkflowDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
return (
<Column
key={columnDef.id}
column={columnDef.id as ColumnType}
workflowMode
workflowId={selectedWorkflow.id}
columnDisplayName={columnDef.name}
columnDescription={columnDef.description}
columnFlags={columnDef.flags}
holdTaskIds={holdTaskIds}
workflowContextMenuColumns={selectedWorkflowContextMenuColumns}
tasks={selectedWorkflowTasksByColumn[columnDef.id] ?? []}
allTasks={selectedWorkflowTasks}
projectId={projectId}
maxConcurrent={maxConcurrent}
showWorktreeGrouping={showWorktreeGrouping}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={canDropTaskBinder(columnDef.id, selectedWorkflow.id)}
getDraggingTaskId={getDraggingTaskId}
onPauseTask={onPauseTask}
onUnpauseTask={onUnpauseTask}
onResetTask={onResetTask}
onDuplicateTask={onDuplicateTask}
onMergeTask={onMergeTask}
onOpenDetail={onOpenDetail}
onPlanningMode={onPlanningMode}
onOpenRefine={onOpenRefine}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
availableModels={availableModels}
onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
mergeStrategy={mergeStrategy}
// FNXC:PlanApproval 2026-07-07-00:00: FN-7653 — the plan auto-approve shortcut belongs only to the intake/planning column, never to hold (Todo-like) columns; the built-in Coding workflow's Todo column carries the hold trait and was wrongly receiving this prop pair.
{...((columnDef.flags.intake && !columnDef.flags.archived && !columnDef.flags.complete && !columnDef.flags.countsTowardWip && !columnDef.flags.mergeBlocker && !columnDef.flags.humanReview) ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
{...(isCreateColumn ? { workflowOptions, defaultWorkflowId: selectedWorkflow.id, onQuickCreate: handleWorkflowQuickCreate, onNewTask: handleSelectedWorkflowNewTask, onSubtaskBreakdown } : {})}
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(columnDefOffersArchiveAllDone(columnDef) ? { onArchiveAllDone } : {})}
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
/>
);
})}
{partitionRevertedTasks(selectedWorkflowTasks).reverted.length > 0 && (
<section className="reverted-tasks-section" aria-label="Reverted Tasks" data-testid="board-reverted-tasks">
<h2>Reverted Tasks</h2>
{partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={blockerFanoutColumnFlagsByTaskId.get(task.id)} onOpenDetail={onOpenDetail} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} disableDrag />)}
</section>
)}
{selectedWorkflowArchivedColumn && (
<Column
key={selectedWorkflowArchivedColumn.id}
column={selectedWorkflowArchivedColumn.id as ColumnType}
workflowMode
workflowId={selectedWorkflow.id}
columnDisplayName={selectedWorkflowArchivedColumn.name}
columnDescription={selectedWorkflowArchivedColumn.description}
columnFlags={selectedWorkflowArchivedColumn.flags}
holdTaskIds={holdTaskIds}
workflowContextMenuColumns={selectedWorkflowContextMenuColumns}
tasks={selectedWorkflowTasksByColumn[selectedWorkflowArchivedColumn.id] ?? []}
allTasks={selectedWorkflowTasks}
projectId={projectId}
maxConcurrent={maxConcurrent}
showWorktreeGrouping={showWorktreeGrouping}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={canDropTaskBinder(selectedWorkflowArchivedColumn.id, selectedWorkflow.id)}
getDraggingTaskId={getDraggingTaskId}
onPauseTask={onPauseTask}
onUnpauseTask={onUnpauseTask}
onResetTask={onResetTask}
onDuplicateTask={onDuplicateTask}
onMergeTask={onMergeTask}
onOpenDetail={onOpenDetail}
onPlanningMode={onPlanningMode}
onOpenRefine={onOpenRefine}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
availableModels={availableModels}
onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
mergeStrategy={mergeStrategy}
collapsed={archivedCollapsed}
onToggleCollapse={handleToggleArchivedCollapse}
archivedHasMore={archivedHasMore}
archivedLoadingMore={archivedLoadingMore}
onLoadMoreArchived={onLoadMoreArchivedTasks}
/>
)}
</main>
</div>
);
}
/*
FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9, R8):
The legacy single-lane board is DELETED. It mapped the hardcoded `COLUMNS` enum
and was the last board surface deriving its column set from the legacy vocabulary
rather than from each card's own workflow — an R8 violation that survived U10.
It was also unreachable. The skeleton gate above returns unless
`boardWorkflows.workflows.length > 0`, which is exactly what makes `workflowMode`
true; and `useBoardWorkflows` resolves `selectedWorkflow` to `workflowOptions[0]`
when neither the stored nor the default selection matches, so it is non-null
whenever a lane exists. The workflow branch above is therefore always taken.
This arm remains only to narrow `selectedWorkflow` without a non-null assertion.
Rendering the skeleton rather than throwing keeps a hypothetical unreachable
state a blank frame instead of a crashed board.
*/
return <BoardWorkflowSkeleton empty={false} />;
}