Files
fusion/packages/dashboard/app/components/Board.tsx
gsxdsm 79d4299be2 fix: preserve provider and workflow behavior after migration
Use canonical Anthropic OAuth refresh, keep CLI-backed providers out of API-key auth rows, parse Grok's omitted zero usage, and carry board workflow context into task creation.
2026-07-14 15:07:26 -07:00

1191 lines
60 KiB
TypeScript

import type { Task, TaskDetail, Column as ColumnType, ColumnId, TaskCreateInput, GithubIssueAction, MergeResult } from "@fusion/core";
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
import { sortTasksForDisplayColumn, type DoneColumnSortMode } from "./taskSorting";
import { Column } from "./Column";
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 } from "../hooks/useBlockerFanout";
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 {
ALL_WORKFLOWS_BOARD_VIEW_ID,
readBoardWorkflowViewSelection,
removeBoardWorkflowSelection,
writeBoardWorkflowSelection,
} from "../utils/boardWorkflowSelection";
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
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>;
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;
/** 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;
/** 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. */
workflowColumnsEnabled?: boolean;
/** Whether app settings have loaded; false gates the legacy board until the workflow flag is known. */
settingsLoaded?: boolean;
/** Relocates workflow controls into the Header portal slot when sidebar navigation owns the inline chrome. */
workflowControlsInHeader?: boolean;
}
function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
if (previous.length !== next.length) return false;
return previous.every((task, index) => task === next[index]);
}
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>
);
}
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, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, 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 [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
if (typeof document === "undefined") return null;
return document.getElementById("header-workflow-slot");
});
const viewportMode = useViewportMode();
const blockerFanoutMap = useBlockerFanout(tasks, {
staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs,
});
// Normalized search-active signal: trimmed and non-empty
const isSearchActive = searchQuery.trim() !== "";
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
triage: [],
todo: [],
"in-progress": [],
"in-review": [],
done: [],
archived: [],
});
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]);
// Tasks are already server-filtered when searchQuery is active (via useTasks hook).
// Client-side filtering is removed - tasks prop is used directly.
// Keep per-column array identities stable for unchanged columns so React.memo(Column)
// can skip sibling rerenders during unrelated task updates.
const tasksByColumn = useMemo(() => {
const nextGrouped: Record<ColumnType, Task[]> = {
triage: [],
todo: [],
"in-progress": [],
"in-review": [],
done: [],
archived: [],
};
for (const task of tasks) {
const column = isColumn(task.column) ? task.column : DEFAULT_COLUMN;
const bucket = nextGrouped[column] ?? nextGrouped[DEFAULT_COLUMN];
bucket.push(task);
}
const previousGrouped = tasksByColumnCacheRef.current;
const stableGrouped = {} as Record<ColumnType, Task[]>;
for (const column of COLUMNS) {
const sortedTasks = column === "done"
? sortTasksForDisplayColumn(nextGrouped[column], column, doneSortMode)
: sortTasksForDisplayColumn(nextGrouped[column], column);
stableGrouped[column] = areTaskArraysEqual(previousGrouped[column], sortedTasks)
? previousGrouped[column]
: sortedTasks;
}
tasksByColumnCacheRef.current = stableGrouped;
return stableGrouped;
}, [tasks, doneSortMode]);
/*
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 (flag-gated) ──────────────────────────────────────
/*
FNXC:BoardWorkflows 2026-06-20-08:58:
Workflow-columns-enabled users must never see the legacy single-lane 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 settings or uncached workflow metadata are unknown.
FNXC:Workflows 2026-06-22-17:00:
The board-workflows fetch/cache/SSE/selection loop now lives in `useBoardWorkflows`, shared verbatim with the Planning header slot. Board gates cache hydration on `workflowColumnsEnabled === true || settingsLoaded === false` so workflow-columns users never flash the legacy board, and consumes the exposed raw state setter for optimistic task→workflow assignment. When the flag is OFF the server returns `{ flagEnabled: false }` and we render the legacy single-lane board below.
*/
const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false;
const {
boardWorkflows,
workflowMode,
workflowOptions,
selectedWorkflow,
selectedWorkflowId,
setSelectedWorkflowId,
refreshBoardWorkflows,
setBoardWorkflowsState,
} = useBoardWorkflows({ projectId, shouldHydrateCache: shouldHydrateBoardWorkflowsCache });
const draggingTaskIdRef = useRef<string | null>(null);
const handlePromote = useCallback(async (taskId: string) => {
await promoteTask(taskId, projectId);
}, [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.
*/
const boardWorkflowsRef = useRef(boardWorkflows);
boardWorkflowsRef.current = boardWorkflows;
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
const lastUnmappedTaskSignatureRef = useRef<string | null>(null);
const unmappedRefetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/*
FNXC:WorkflowBoard 2026-07-12-23:40:
The FN-7591 refetch must also fire for a PRESENT-but-unrepresentable mapping, not only an
absent one. The server emits a taskWorkflowIds entry for every task (defaulting to the
default workflow), so a stale selection row makes e.g. an "ideas"-column card map to plain
Coding — an entry that exists but whose workflow does not declare the task's column. The
`=== undefined` guard alone never re-fired for those, leaving the card permanently
invisible in the aggregate view. A mapping is "suspect" when the resolved workflow's
column set does not contain the task's stored column. The signature guard still prevents
refetch loops for mappings that stay wrong after a fresh fetch.
*/
const isTaskWorkflowMappingSuspect = useCallback((
payload: NonNullable<typeof boardWorkflows>,
task: Task,
): boolean => {
const assigned = payload.taskWorkflowIds[task.id];
if (assigned === undefined) return true;
const known = payload.workflows.some((workflow) => workflow.id === assigned);
const workflowId = known ? assigned : payload.defaultWorkflowId;
const workflow = payload.workflows.find((candidate) => candidate.id === workflowId);
return workflow !== undefined && !workflow.columns.some((column) => column.id === task.column);
}, []);
useEffect(() => {
if (!boardWorkflows || !workflowMode) return;
const unmapped = tasks
.filter((task) => isTaskWorkflowMappingSuspect(boardWorkflows, task))
.map((task) => task.id)
.sort();
if (unmapped.length === 0) {
lastUnmappedTaskSignatureRef.current = null;
return;
}
const signature = unmapped.join(",");
if (signature === lastUnmappedTaskSignatureRef.current) return;
lastUnmappedTaskSignatureRef.current = signature;
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
unmappedRefetchTimerRef.current = setTimeout(() => {
unmappedRefetchTimerRef.current = null;
const latestWorkflows = boardWorkflowsRef.current;
if (!latestWorkflows) return;
const stillUnmapped = tasksRef.current.some((task) => isTaskWorkflowMappingSuspect(latestWorkflows, task));
if (stillUnmapped) refreshBoardWorkflows({ forceFresh: true });
}, 0);
}, [boardWorkflows, isTaskWorkflowMappingSuspect, refreshBoardWorkflows, tasks, workflowMode]);
useEffect(() => () => {
if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current);
}, []);
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 })));
}
return map;
}, [boardWorkflows]);
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) {
const columnId = grouped[task.column] !== undefined
? task.column
: (selectedWorkflowCreateColumnId ?? task.column);
(grouped[columnId] ??= []).push(task);
}
for (const column of selectedWorkflow.columns) {
/*
FNXC:DoneColumnSorting 2026-06-29-20:20:
Workflow-mode Done sorting follows the workflow trait, not only the built-in `done` id, so custom complete lanes get the same descending completion-date/task-id selector while archived lanes keep their own behavior.
*/
const isWorkflowDoneLikeColumn = column.flags.complete === true && column.flags.archived !== true;
grouped[column.id] = isWorkflowDoneLikeColumn
? sortTasksForDisplayColumn(grouped[column.id] ?? [], "done", doneSortMode)
: sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType, doneSortMode, column.flags.archived === true);
}
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.
*/
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] });
}
}
}
for (const column of COLUMNS) {
if (!byId.has(column)) {
byId.set(column, {
id: column,
name: column,
flags: { archived: column === "archived", complete: column === "done", intake: column === "triage", countsTowardWip: column === "in-progress", mergeBlocker: column === "in-review" },
sourceWorkflowIds: [],
});
}
}
const order = new Map(COLUMNS.map((column, index) => [column, index]));
return [...byId.values()].sort((a, b) => {
const aOrder = order.get(a.id as ColumnType) ?? (a.flags.archived ? 10_000 : 1_000);
const bOrder = order.get(b.id as ColumnType) ?? (b.flags.archived ? 10_000 : 1_000);
if (aOrder !== bOrder) return aOrder - bOrder;
return a.name.localeCompare(b.name);
});
}, [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],
);
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 (!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) {
const isDoneLikeColumn = column.flags.complete === true && column.flags.archived !== true;
grouped[column.id] = isDoneLikeColumn
? sortTasksForDisplayColumn(grouped[column.id] ?? [], "done", doneSortMode)
: sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType, doneSortMode, column.flags.archived === true);
}
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]);
// 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.
const shouldGateLegacyBoard = boardWorkflows === null
? (workflowColumnsEnabled === true || settingsLoaded === false)
: boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0;
if (shouldGateLegacyBoard) {
return <BoardWorkflowSkeleton empty={boardWorkflows?.flagEnabled === true} />;
}
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={boardRef}>
{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}
columnFlags={columnDef.flags}
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}
taskStuckTimeoutMs={taskStuckTimeoutMs}
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 } : {})}
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
{...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
{...(columnDef.flags.archived ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived: onLoadMoreArchivedTasks } : {})}
/>
);
})}
</main>
</div>
);
}
return (
<div className="board-workflow-view">
{renderedWorkflowToolbar}
<main
className="board board-workflow-columns"
id="board"
ref={boardRef}
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}
columnFlags={columnDef.flags}
workflowContextMenuColumns={selectedWorkflowContextMenuColumns}
tasks={selectedWorkflowTasksByColumn[columnDef.id] ?? []}
allTasks={selectedWorkflowTasks}
projectId={projectId}
maxConcurrent={maxConcurrent}
showWorktreeGrouping={showWorktreeGrouping}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={(taskId) => canDropTask(taskId, 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}
taskStuckTimeoutMs={taskStuckTimeoutMs}
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 } : {})}
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
/>
);
})}
{selectedWorkflowArchivedColumn && (
<Column
key={selectedWorkflowArchivedColumn.id}
column={selectedWorkflowArchivedColumn.id as ColumnType}
workflowMode
workflowId={selectedWorkflow.id}
columnDisplayName={selectedWorkflowArchivedColumn.name}
columnFlags={selectedWorkflowArchivedColumn.flags}
workflowContextMenuColumns={selectedWorkflowContextMenuColumns}
tasks={selectedWorkflowTasksByColumn[selectedWorkflowArchivedColumn.id] ?? []}
allTasks={selectedWorkflowTasks}
projectId={projectId}
maxConcurrent={maxConcurrent}
showWorktreeGrouping={showWorktreeGrouping}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={(taskId) => canDropTask(taskId, 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}
taskStuckTimeoutMs={taskStuckTimeoutMs}
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>
);
}
return (
<>
<main className="board" id="board" ref={boardRef}>
{COLUMNS.map((col) => (
<Column
key={col}
column={col}
tasks={tasksByColumn[col]}
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}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
mergeStrategy={mergeStrategy}
{...(col === "triage" ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
{...(col === "triage" ? { onQuickCreate, onNewTask, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived: onLoadMoreArchivedTasks } : {})}
/>
))}
</main>
</>
);
}