Files
fusion/packages/dashboard/app/components/Column.tsx
gsxdsm 642a4fa264 consolidate/u12 — U12 consolidation: 4 live defects, the AST ratchet fail-closed, and the moves.ts flag scoped (#2647)
One branch, one PR, per the consolidation directive. Contents
file-by-file below.

**Supersedes #2625** (its overlapping conversions landed via U11's
#2624/#2626/#2636; only the parts nobody else did are folded here).
**#2630 and #2639 stay open** — both green with zero threads, per rule
3.

## Four live defects, each measured

**1. Every planning card renders an actions menu.**
`TaskContextMenu.tsx` still had `shouldShowActionsMenu: task.column !==
"triage"` on main *after* the rest of that file was converted. Since
#2515 removed the id, the condition is TRUE for every card, so the
suppression stopped applying anywhere — including on cards whose menu is
empty, the orphaned click target the Surface Enumeration rule exists to
catch.

Found **twice independently**: by reading the guard, and again by the
invariance test below, which failed on main with `shouldShowActionsMenu`
true on one lineage and false on another. That is the argument for an
invariance property over per-site conversion — the file had already been
converted "2 → 1" and the survivor was the live one.

**2. Worktree upcoming-work list empty on renamed boards.**
`groupByWorktree` filtered `t.column === "todo"`. On the default board
the id and the role coincide so every existing test passed; renamed, it
matched nothing and a whole panel read as idle.

**3. Hold-lane FIFO ordering lost on renamed boards.**
`sortTasksForDisplayColumn` gated priority-then-FIFO on `column ===
"todo"`, degrading to the generic id-ordered sort elsewhere. Cards
simply appear in the wrong order, silently.

**4. The AST ratchet still failed open** — fourth time in that file,
third found by review. `receiverName` understood only one-level property
access and bare identifiers, so `task["column"]`, `metadataColumn(entry,
"to")`, ternaries, `(task!.column)` and backtick literals were dropped.
**Measured on main: `in-progress` 196 → 197, `in-review` 211 → 213** —
three real guards nobody counted, including `metadataColumn(entry, "to")
=== "in-review"` in `reliability-metrics.ts`. Now walks wrappers,
resolves calls to the callee name, and emits a `<SyntaxKind>`
**sentinel** for anything unnameable: counted *and* trips the
classification guard, so a human judges it instead of it vanishing.

## Per-file guard counts

| file | before | after |
|---|---:|---:|
| `app/components/TaskContextMenu.tsx` | 1 | **0** |
| `app/utils/worktreeGrouping.ts` | 1 | **0** |
| `app/components/taskSorting.ts` | 1 | **0** |

The other dashboard files I had converted reached 0 via U11's PRs; where
our work overlapped I took theirs during the rebase, including two
places where theirs was **stronger** than mine — they deleted Column's
unreachable quick-create arm outright (with fixtures migrated) where I
had converted it, and they verified the same `isPreExecutionHoldColumn`
degraded-set asymmetry I did, independently.

## Flip precondition: the moves.ts flag is scoped, not flipped

`move-target-declared-census.test.ts` answers precondition 2 with
measurement. 41 engine `moveTask` calls have literal targets — `todo`
27, `in-progress` 7, `done` 6, `archived` 1 — and **all four are
declared by the default lineage**, so the default board is not the
exposure. `triage` appears only in a comment noting `replan-target.ts`
used to hardcode it. My own grep had said `todo=29`; the AST says 27,
because grep counts comments.

The exposure is **custom** lineages: 20 of the 41 carry no
`recoveryRehome` and would reject with unknown-column post-flip; 21 are
exempt via the #1411 carve-out, which makes that carve-out load-bearing.

I did not flip the flag. It is six seams, not the `789`/`837` pair every
summary including mine described, and seam 2 turns on *new refusals*
rather than swapping equivalent implementations — a green suite says
nothing about that. #2639 pins the blast radius.

## Tests

- `column-role-id-invariance.test.tsx` — hold traits fixed, vary only
the column id across MERGED / LEGACY / RENAMED; every decision must
agree. Drives the real consumers, so a component keeping an inline
comparison fails it. Includes a unanimous-and-**false** case so it can't
be satisfied by a predicate hardwired to true. **This is the test that
caught defect 1 on main.**
- `worktreeGrouping.test.ts` — includes two cards both in a column named
`staging`, one hold and one not, asserting opposite answers. That
assertion is impossible under a board-wide column-id set, which is why
hold resolution is keyed per task via `getEffectiveTaskWorkflowId`
(#2625 review).
- `taskSorting.test.ts` — discriminates on the **tiebreak**, not
priority: both branches sort by priority, so my first version passed for
the wrong reason. Equal-priority cards whose `createdAt` order disagrees
with their id order.
- `no-hardcoded-lifecycle-columns.test.ts` — 16 detector cases: 11
shapes counted, 4 legitimate ignored, one asserting the sentinel path.

Revert checks, all run: menu suppression → diff names the field;
worktree → `expected [] to include 'FN-50'`; sort → `FN-2, FN-9` instead
of `FN-9, FN-2`; ratchet → the 3 recovered guards disappear.

## One site that should never be converted

`MissionControlPanel.tsx:46` — `{ id: "triage", match: (c) => c ===
"triage" || c === "signal" || c === "backlog" }` is a deliberate
name-similarity heuristic for the SDLC funnel; it matches synonyms and
folds unknown columns into an "other" bucket so custom columns still
contribute. Converting it changes what the funnel displays. Like the
`live-agent-count` fallbacks, it belongs in a documented floor — **the
ratchet's target is that floor, not zero.**

`DocumentsView.tsx:73` is convertible but the file has no column flags
at all, so a real fix means plumbing board-workflow metadata into a view
that doesn't fetch it — its own unit of work.

## Verification

`pnpm lint` clean. `pnpm test:gate` green (10 / 482 / 71). `tsc -p
packages/dashboard/tsconfig.app.json` and `packages/core/tsconfig.json`
clean. Core ratchet + seam suites 24/24. Dashboard target suites 37/38 —
the one failure is the pre-existing `"Back to In Progress"` label
casing, confirmed identical on the base.

---

## Added after the initial push

**5. `TaskCard` lost inline editing on renamed boards; `TaskDetailModal`
kept it.** Still live on main: the modal resolved field editability from
traits in U10/R8, the card used a hardcoded `{triage, todo}` set with
**no trait path at all** — even though `taskColumnFlags` was already in
scope. On a renamed board the title was editable in the modal and the
pencil was missing from the card. Body moved unchanged into
`isFieldEditableColumnRole` so the two surfaces cannot drift again.

The veto traits are the substance: a column can legally carry `hold`
**and** a WIP or review trait, and a plain `intake || hold` check would
let an operator rewrite a description while a session executes against
it.

Coverage gap **measured, not assumed**: mutating `canEdit` back to the
hardcoded set left `TaskCard*` at the same failure count as the
unmutated run — nothing caught it. The four render cases assert the real
`aria-label`; that mutation now fails with `Unable to find an accessible
element ... name 'Edit task'`.

**6. The ratchet's target is a documented FLOOR, not zero** — and this
changes the completion bar.

Zero is not reachable, and chasing it means breaking working code. Two
categories are permanent, now protected as positive assertions so a
future sweep cannot "finish the job" by deleting them:

- `MissionControlPanel.tsx`'s `FUNNEL_STAGES` is a deliberate
**name-similarity** heuristic — it matches `signal`, `backlog`, `to-do`,
`ready`, `shipped` and folds unrecognised columns into an "other" bucket
so a custom board still contributes counts. It is not asking whether a
column has the intake trait; it buckets arbitrary column *names* for
display. Asserted on the **synonym list**, because the synonyms are what
prove it is name matching — if they disappear the site has changed
character and the exemption stops applying.
- `live-agent-count.ts`'s no-flags arm is reachable (a remote store is
deliberately given an empty flag map; a card in an undeclared column has
no flags at all) and deleting the literal makes such a card match **no**
arm, so the queued total silently under-reports a stranded card.

A count with an undocumented floor invites someone to drive it to zero.

**Not done, and why:** `DocumentsView.tsx:73` is convertible but that
file has no column flags anywhere, so a real fix means plumbing
board-workflow metadata into a view that does not fetch it — its own
unit of work, not something to smuggle into a conversion.

**Re-verified after these commits:** `pnpm lint` clean, `pnpm test:gate`
green (10 / 482 / 71), `tsc` clean on core and `tsconfig.app.json`, core
ratchet suite 26/26, `columnRoles` 10/10, `TaskCard.test.tsx` 384/386
(the 2 are pre-existing CSS assertions). `TaskDetail*` is 130 failed /
551 passed **both with and without** this change — verified by stashing,
so pre-existing and unrelated.

---

## Flag resolution: preconditions 1 and 2 are now DISCHARGED.
Precondition 3 is blocked, and by evidence.

**Precondition 1 — the side-effect equivalence proof — done.**
`moves-flag-equivalence.test.ts` runs the same journey under both flag
states against live PG and diffs the persisted row. **Result:
identical** — whole-row equality across 128 fields plus an equal timing
shape, over `todo → in-progress → in-review → todo → in-progress`.

That test was **wrong twice** before it meant anything, and both times
it was passing:

1. **It proved nothing.** `experimentalFeatures` is **global-only**, and
`moves.ts` reads `getSettingsFast()`, which filters global-only keys out
of the project layer. My `updateSettings` write was silently discarded,
`useWorkflow` was false in *both* runs, and the "proof" compared the
legacy path against itself. Found by stamping the flag-ON branch and
observing the test still passed. Now written via `updateGlobalSettings`,
and the helper **asserts the flag took effect** before the journey runs.
2. **The journey was forward-only**, so it never reached the reopen
hook's field resets (`status`, `error`, `blockedBy`, pause clearing) — a
mutation there passed. Extended with a backward move and a re-entry.

Mutation-verified after both fixes: stamping seam 3, and diverging the
reopen hook, each fail the comparison.

**Precondition 2 — done, and its answer is a blocker.** The census says
the default board is safe: all 41 literal engine move targets are
declared by the default lineage. But **20 of those 41 carry no
`recoveryRehome`**, so on a custom lineage that does not declare `todo`
/ `in-progress` / `done`, seam 2 would start rejecting them with
unknown-column. That is a user-facing break on custom boards, not a
theoretical one, and it is not fixed by the equivalence proof — seam 2
adds *new refusals* rather than swapping implementations.

**So the flip is one step away, and the step is not mine to take
alone:** those 20 call sites need to resolve their target from the
task's workflow (or justify `recoveryRehome`), and they live across
engine lanes in `moves.ts` caller territory — U2b/MAIN. Flipping before
that trades a dormant flag for broken custom boards.

What remains for precondition 3 once those land: flip both readers
**atomically** (`moves.ts` + `workflow-task-create-ops.ts`, since the
latter computes the preflight the former consumes), delete the flag-OFF
branch with its guards, and drop the settings key.

---

## CORRECTION: seam 2 is not a blocker. My earlier claim was wrong.

I stated in #2639 and above that "with the flag off there is **no**
target-column validation on the move path", so flipping would introduce
new refusals. **That is not what happens.** Reproduced against live PG:
the identical custom-lineage move rejects with the flag **OFF** as well
—

```
Error: Invalid transition: 'backlog' -> 'todo'. Valid targets: building
```

Transition validation is already in force on the flag-OFF path. So for
the shape in question — an engine move to a column the task's own
workflow does not declare — **the move already fails today**, and seam 2
introduces no new break for it. The 20 census sites lacking
`recoveryRehome` are broken on a custom lineage *now*, not broken by the
flip.

I found this because the discriminator I added to prove "the flag is the
cause" failed. Had I written the test to my assumption it would have
passed and the false claim would have shipped — the same way the
equivalence test passed while proving nothing until I tried to make it
fail.

**Revised precondition status:**

| precondition | status |
|---|---|
| 1 — side-effect equivalence | **discharged** — identical rows,
mutation-verified both directions |
| 2 — seam-2 exposure census | **discharged, and it is not a blocker** —
the rejection predates the flag |
| 3 — flip both readers atomically, delete the flag-OFF branch, drop the
settings key | **the remaining work** |

So the flip is no longer gated on fixing 20 engine call sites. What it
is still gated on is precondition 3 being done atomically across
`moves.ts` and `workflow-task-create-ops.ts` (the latter computes the
preflight the former consumes), which is `moves.ts` caller territory.

Three cases now cover seam 2: the flag-ON rejection, the flag-OFF
rejection (asserting the error *message*, so a change in which guard
rejects stays visible rather than reading as agreement), and the #1411
`recoveryRehome` carve-out succeeding — pinning why that carve-out is
load-bearing and must not be tidied away.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:13:58 -07:00

1173 lines
60 KiB
TypeScript

import { memo, useMemo, useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
import { useConfirm } from "../hooks/useConfirm";
import type { Task, TaskDetail, Column as ColumnType, ColumnId, TaskCreateInput, GithubIssueAction, MergeResult } from "@fusion/core";
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS, getErrorMessage } from "@fusion/core";
import { enrichRunningAgentTaskShapeFromFlags, isRunningAgentTask } from "../../../core/src/live-agent-count";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
import { TaskCard } from "./TaskCard";
import { WorktreeGroup } from "./WorktreeGroup";
import { QuickEntryBox } from "./QuickEntryBox";
import { PluginSlot } from "./PluginSlot";
import { groupByWorktree } from "../utils/worktreeGrouping";
import { isTaskAgentActive } from "../utils/taskActivity";
import { isPreImplementationColumnRole } from "../utils/columnRoles";
import { isTaskStuck } from "../utils/taskStuck";
import type { ToastType } from "../hooks/useToast";
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
import { ChevronDown, ChevronUp, MoreVertical } from "lucide-react";
import type { BoardWorkflowDefinition, ModelInfo, BoardWorkflowColumnFlags, RevertTaskOptions, RevertTaskResult } from "../api";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
import type { DoneColumnSortMode } from "./taskSorting";
const PAGINATED_COLUMN_THRESHOLD = 100;
const VISIBLE_TASKS_INITIAL = 50;
const VISIBLE_TASKS_INCREMENT = 25;
/** Shape of a structured transition rejection carried in a 409's `details`. */
interface TransitionRejectionDetail {
code: string;
messageKey: string;
retryable: boolean;
}
/**
* Pull a typed transition rejection out of an `ApiRequestError`'s `details`
* (the structured 409 the move/promote endpoints emit under the workflowColumns
* flag). Returns null for any other error shape (legacy errors are unchanged).
*/
export function extractTransitionRejection(err: unknown): TransitionRejectionDetail | null {
const details = (err as { details?: Record<string, unknown> } | null)?.details;
if (!details || typeof details !== "object") return null;
const { code, messageKey, retryable } = details as Record<string, unknown>;
if (typeof code === "string" && typeof messageKey === "string") {
return { code, messageKey, retryable: retryable === true };
}
return null;
}
/**
* Resolve a rejection (by stable code, falling back to its messageKey) to
* user-facing copy. The static `t()` literals here are what the i18next
* extractor sees, so the `board.rejection.*` keys persist in the catalog and
* the surfaces show real copy rather than a raw key. The `messageKey` carried by
* the rejection is still honored as the lookup so a server-chosen non-default
* key resolves correctly.
*/
type TFn = (key: string, defaultValue: string) => string;
export function translateRejection(t: TFn, rejection: TransitionRejectionDetail): string {
switch (rejection.code) {
case "guard-rejected":
return t("board.rejection.guardRejected", "This move is not allowed by the workflow.");
case "capacity-exhausted":
return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up.");
case "unknown-column":
return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow.");
case "workflow-mismatch":
return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead.");
case "merge-blocked":
return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes.");
/*
FNXC:BoardRejections 2026-07-25-04:55:
FN-8471 added this server-side code without a client case or catalog entry, so
the default branch fell through to `t(messageKey, messageKey)` and the board
printed the raw `board.rejection.unplannedForExecution` key at operators. The
static literal here is also what the i18next extractor sees, so the key must
be spelled out in the switch rather than resolved via the carried messageKey.
*/
case "unplanned-for-execution":
return t(
"board.rejection.unplannedForExecution",
"This task isn't ready for execution yet — planning or plan review is still outstanding.",
);
default:
return t(rejection.messageKey, rejection.messageKey);
}
}
/** Translate a bare drag pre-check messageKey (R17 no-move) to copy. The same
* static literals as {@link translateRejection} so the extractor keeps them. */
export function translateRejectionKey(t: TFn, messageKey: string): string {
switch (messageKey) {
case "board.rejection.guardRejected":
return t("board.rejection.guardRejected", "This move is not allowed by the workflow.");
case "board.rejection.capacityExhausted":
return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up.");
case "board.rejection.unknownColumn":
return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow.");
case "board.rejection.workflowMismatch":
return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead.");
case "board.rejection.mergeBlocked":
return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes.");
case "board.rejection.unplannedForExecution":
return t(
"board.rejection.unplannedForExecution",
"This task isn't ready for execution yet — planning or plan review is still outstanding.",
);
default:
return t(messageKey, messageKey);
}
}
interface ColumnProps {
column: ColumnType;
tasks: Task[];
projectId?: string;
maxConcurrent: number;
showWorktreeGrouping: boolean;
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => 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?: () => void;
autoMerge?: boolean;
/** Project merge strategy for Task Detail-equivalent card context actions. */
mergeStrategy?: string;
onToggleAutoMerge?: () => void;
planAutoApproveEnabled?: boolean;
onTogglePlanAutoApprove?: () => void;
globalPaused?: boolean;
onUpdateTask?: (
id: string,
updates: { title?: string; description?: string; dependencies?: string[]; githubTracking?: { enabled?: boolean } }
) => 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[]>;
/** Current Done-column display order, supplied only for the board's Done surface. */
doneSortMode?: DoneColumnSortMode;
/** Updates the board-local Done-column display order. */
onDoneSortModeChange?: (mode: DoneColumnSortMode) => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
/** FNXC:ArchivePagination 2026-07-08-00:00: FN-7659 — whether another archived page (beyond what's currently loaded) is available. Drives the archived column's server-backed "Show more" button. */
archivedHasMore?: boolean;
/** True while a "Show more" archived page fetch is in flight. */
archivedLoadingMore?: boolean;
/** Fetches the next 100-item page of archived tasks (newest-first). */
onLoadMoreArchived?: () => Promise<void>;
allTasks?: Task[];
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;
/** When true, search is active — bypass pagination so all matching tasks are visible. */
isSearchActive?: boolean;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
/** Trusted aggregate-board workflow badges keyed by task id; omitted in per-workflow and non-board surfaces. */
taskWorkflowBadges?: ReadonlyMap<string, { workflowId: string; workflowName: string; workflowIcon?: string }>;
/** Precomputed blocker fanout keyed by blocker task ID. */
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
prAuthAvailable?: boolean;
// ── U9 workflow-columns (flag-ON) additive props ─────────────────────────
/** True when the board is in multi-lane workflow mode (flag ON). Switches
* column behavior (label, bulk actions, archived detection) from legacy
* literals to trait-flag predicates. Flag OFF leaves all behavior legacy. */
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
The ids of tasks whose own column is a hold lane in THEIR OWN workflow, for the worktree
grouping's upcoming-work list. Task ids rather than column ids because column ids are
namespaced per workflow and two workflows can disagree about the same name (PR #2625
review). Board resolves it; Lane does not pass it and keeps the legacy-id fallback.
*/
holdTaskIds?: ReadonlySet<string>;
workflowMode?: boolean;
/** Workflow id for column-aware task creation in workflow mode. */
workflowId?: string;
/** Real workflow choices for the quick-add selector in workflow mode. */
workflowOptions?: BoardWorkflowDefinition[];
/** Default workflow target for quick-add when the parent view is aggregate or stale. */
defaultWorkflowId?: string | null;
/** Display name for this column, from the workflow definition. */
columnDisplayName?: string;
/** Optional explanatory copy from the workflow definition. */
columnDescription?: string;
/** Resolved trait flags for this column (workflow mode). */
columnFlags?: BoardWorkflowColumnFlags;
/** Ordered workflow columns for deriving context-menu move targets in workflow mode. */
workflowContextMenuColumns?: readonly TaskContextMenuColumnMetadata[];
/** Per-task workflow columns for aggregate Board cards whose tasks come from different workflows. */
taskContextMenuColumnsByTaskId?: ReadonlyMap<string, readonly TaskContextMenuColumnMetadata[]>;
/** Manually promote a held card out of this hold column (workflow mode). */
/** `force` waives the unplanned-for-execution gate after operator confirmation. */
onPromote?: (taskId: string, options?: { force?: boolean }) => Promise<void>;
/**
* Pre-check whether a drop into THIS column is allowed for the dragged task.
* Returns null for "allowed", or an i18n messageKey for a deterministic
* rejection (guard/capacity/unknown-column/workflow-mismatch). When a
* rejection is returned, dragover is NOT prevented, so the card never renders
* in this column (no-move semantics, R17). The dragged task id is read from a
* board-level ref set on dragstart.
*/
canDropTask?: (taskId: string) => string | null;
/** Read the id of the task currently being dragged (board-level ref). */
getDraggingTaskId?: () => string | null;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, 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, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection
// helper's calls are not statically discovered). These resolve the same copy.
const rejectionCopy = useMemo(() => ({
guardRejected: t("board.rejection.guardRejected", "This move is not allowed by the workflow."),
capacityExhausted: t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."),
unknownColumn: t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."),
workflowMismatch: t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."),
mergeBlocked: t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."),
promoteRejected: t("board.rejection.promoteRejected", "This card could not be promoted."),
}), [t]);
void rejectionCopy;
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isReplanning, setIsReplanning] = useState(false);
const [isPausingAll, setIsPausingAll] = useState(false);
const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false);
// Workflow mode: per-card promote in-flight ids + inline capacity feedback.
const [promotingIds, setPromotingIds] = useState<ReadonlySet<string>>(() => new Set());
const [inlineFeedback, setInlineFeedback] = useState<string | null>(null);
/*
FNXC:WorkflowColumnDescriptions 2026-07-22-12:30:
Whitespace-only values can exist in pre-editor/custom IR. Treat them as
absent so they retain lifecycle fallback rather than creating a blank shell.
*/
const resolvedColumnDescription = columnDescription?.trim() ? columnDescription : COLUMN_DESCRIPTIONS[column];
const menuRef = useRef<HTMLDivElement | null>(null);
const countFlashing = useFlashOnIncrease(tasks.length);
const { confirm } = useConfirm();
const resolveNearDuplicateCanonicalInactive = useCallback((task: Task): boolean | undefined => {
const nearDuplicateOf = task.sourceMetadata?.nearDuplicateOf;
if (typeof nearDuplicateOf !== "string" || !allTasks) {
return undefined;
}
return isNearDuplicateCanonicalInactive(allTasks.find((candidate) => candidate.id === nearDuplicateOf));
}, [allTasks]);
// Clear the inline capacity-exhausted banner once the column's task list
// changes via SSE (e.g. an occupant moves out and capacity frees up). The
// banner reflects a point-in-time promote rejection; a changed roster means
// the stale constraint may no longer hold. Keyed on the task-id signature so
// it only fires on real membership changes, not every parent re-render.
const taskIdSignature = useMemo(() => tasks.map((task) => task.id).join(","), [tasks]);
useEffect(() => {
setInlineFeedback(null);
}, [taskIdSignature]);
// Close the column dropdown menu when the user clicks anywhere else.
useEffect(() => {
if (!isMenuOpen) return;
function onDocClick(e: MouseEvent) {
if (!menuRef.current?.contains(e.target as Node)) {
setIsMenuOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setIsMenuOpen(false);
}
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, [isMenuOpen]);
// Archived column is collapsed by default - don't show drag state when collapsed.
// Workflow mode keys off the resolved `archived` trait flag instead of the
// literal column id (R9). A hold-flagged column shows the promote affordance.
const isArchived = workflowMode ? Boolean(columnFlags?.archived) : column === "archived";
const isHoldColumn = workflowMode && Boolean(columnFlags?.hold);
const isCollapsed = isArchived && collapsed;
const isWipProcessingColumn = workflowMode ? Boolean(columnFlags?.countsTowardWip) : column === "in-progress";
const getTaskContextMenuColumns = useCallback((task: Task) => (
taskContextMenuColumnsByTaskId?.get(task.id) ?? workflowContextMenuColumns
), [taskContextMenuColumnsByTaskId, workflowContextMenuColumns]);
const getTaskColumnFlags = useCallback((task: Task) => (
getTaskContextMenuColumns(task)?.find((candidate) => candidate.id === task.column)?.flags ?? (task.column === column ? columnFlags : undefined)
), [column, columnFlags, getTaskContextMenuColumns]);
/*
FNXC:WorktreeGroupingSetting 2026-06-27-22:30:
The project setting is an explicit show/hide control: worktree grouping and labels render only when enabled and only for the board's WIP/processing column. Turning it off must leave plain task cards with no legacy group shell in either legacy or workflow-mode columns.
*/
const showWorktreeGroups = showWorktreeGrouping === true && isWipProcessingColumn;
/*
FNXC:BoardColumnCount 2026-07-21-19:30:
Column header is executing/total (e.g. 3/4). Executing uses the same Running predicate as the
footer (unpaused WIP, live planners, active review). Total is the card count in this lane.
FNXC:BoardColumnCount 2026-07-22-06:10:
The header must agree with the cards below it: a Todo card parked in the durable
`needs-replan` stage keeps its REVISING badge and activity chrome (FN-8494), so a header
that only counts live agents read 0/2 under a glowing card. Union the shared Running
predicate with the card's own activity-chrome predicate (isTaskAgentActive, same
globalPaused/stuck gates the card applies) so the count equals the number of visibly
active cards. Footer Running and admission intentionally keep the live-agent-only truth —
a parked replan must not consume top-level concurrency capacity.
*/
const activeTaskCount = useMemo(
() => tasks.filter((task) =>
isRunningAgentTask(enrichRunningAgentTaskShapeFromFlags(task, columnFlags))
// FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2566 review — greptile): these
// tasks are IN this column, so the column's own flags are their column traits. Without
// them the header undercounts executing work on a merged planning lane.
|| isTaskAgentActive(task, { globalPaused, isStuck: isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs), columnFlags }),
).length,
[tasks, columnFlags, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs],
);
/*
FNXC:BoardColumnWindowing 2026-07-26-11:48:
Search used to disable pagination entirely (`!isSearchActive`) so every match rendered at once. That
escape hatch is unbounded — a broad query over a large project mounts an unlimited number of
~4000-line TaskCards, and a resident set that large is a primary reason mobile browsers reclaim the
backgrounded tab (the operator sees a white-splash reload on return). It is also unnecessary: the
`tasks` handed to this column are ALREADY search-filtered upstream, so paginating them still shows
matches — just an increment at a time behind the same "Load more" button. The remaining bypasses are
bounded: the archived column is server-paginated (100 per page) and worktree grouping renders only
the WIP/processing lane.
*/
const shouldPaginate = !isArchived && !showWorktreeGroups && tasks.length > PAGINATED_COLUMN_THRESHOLD;
useEffect(() => {
setVisibleTaskCount((current) => {
if (showWorktreeGroups || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) {
return VISIBLE_TASKS_INITIAL;
}
return Math.min(Math.max(current, VISIBLE_TASKS_INITIAL), tasks.length);
});
}, [showWorktreeGroups, isArchived, tasks.length]);
/*
FNXC:BoardColumnWindowing 2026-07-26-14:20:
Correction of a false claim: the block above previously stated "the window resets whenever the search
term toggles", but the reset effect keyed on `isSearchActive`, a BOOLEAN. Editing a query from one
broad term to another keeps that boolean true, so a window expanded to hundreds of cards by repeated
"Load more" survived into an entirely new result set — reinstating the unbounded DOM this change
removed, via an ordinary search refinement.
Column is not given the query text (Board/Lane pass only `isSearchActive`), and the query string is
not the real invariant anyway: what must stay bounded is the RESULT SET. So the reset keys on a cheap
identity signature of the incoming filtered `tasks` while search is active — length plus the first
and last id. Refining a query changes at least one of those, collapsing the window back to one
screenful; a re-render or poll that yields the same result set produces the same string and does NOT
disturb the operator's expanded window (an effect keyed on the array itself would fire every poll).
A query edit that yields a byte-identical result set intentionally keeps its window: the DOM size is
unchanged, so there is nothing to bound.
*/
const searchResultSignature = useMemo(() => {
if (!isSearchActive || tasks.length === 0) return "";
return `${tasks.length}:${tasks[0]?.id ?? ""}:${tasks[tasks.length - 1]?.id ?? ""}`;
}, [isSearchActive, tasks]);
// Entering/leaving search, or landing on a different search result set, collapses back to one window.
useEffect(() => {
setVisibleTaskCount(VISIBLE_TASKS_INITIAL);
}, [isSearchActive, searchResultSignature]);
const handleDragOver = useCallback((e: React.DragEvent) => {
// Don't allow dropping into archived column via drag-drop
if (isArchived) return;
// Workflow mode (R17): deterministic rejections are NO-MOVE — we do NOT
// call preventDefault, so the browser refuses the drop and the card never
// renders in this column. A null result means the drop is allowed.
if (workflowMode && canDropTask && getDraggingTaskId) {
const draggingId = getDraggingTaskId();
if (draggingId) {
const rejectionKey = canDropTask(draggingId);
if (rejectionKey) {
setInlineFeedback(translateRejectionKey(t, rejectionKey));
return; // no preventDefault → no-move
}
}
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOver(true);
}, [isArchived, workflowMode, canDropTask, getDraggingTaskId, t]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const el = e.currentTarget as HTMLElement;
if (!el.contains(e.relatedTarget as Node)) {
setDragOver(false);
setInlineFeedback(null);
}
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const taskId = e.dataTransfer.getData("text/plain");
if (!taskId) return;
// Check if task is already in this column - if so, skip the API call
const task = tasks.find((t) => t.id === taskId);
if (task && task.column === column) {
return; // No-op: task is already in this column
}
try {
const sourceTask = allTasks?.find((t) => t.id === taskId) ?? task;
const hasStepProgress = sourceTask?.steps.some((step) => step.status !== "pending") ?? false;
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
This component's `column` IS the drop target, so its own `columnFlags` are the
target's traits — no lookup needed, unlike the same prompt in TaskCard/ListView
where the card and the destination differ. Ids remain the fallback for the
no-metadata window.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-30-19:20 (Phase B — consolidated, semantics verified):
Routed through `isPreImplementationColumnRole`. This is the SAME preserve-progress prompt that
helper was written for — ListView asks it about a move target, this component asks it about
itself — and the degraded id sets are identical (`{todo, triage}`), so the consolidation is
exact rather than approximately right.
Verified before consolidating, because the sibling case in TaskContextMenu is NOT
interchangeable: `isPreExecutionHoldColumn` drives the Plan affordance and its degraded set is
`{triage}` alone, so routing THAT through this helper added `plan` to flagless `todo` cards.
Same shape, different degraded answer — matched here, kept separate there.
*/
const shouldPrompt = hasStepProgress && isPreImplementationColumnRole(columnFlags, column);
let moveOptions: { preserveProgress?: boolean } | undefined;
if (shouldPrompt) {
const keepProgress = await confirm({
title: t("column.preserveProgressTitle", "Preserve Progress?"),
message: t("column.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"),
confirmLabel: t("column.keepProgress", "Keep Progress"),
cancelLabel: t("column.resetProgress", "Reset Progress"),
});
if (keepProgress) {
moveOptions = { preserveProgress: true };
} else {
const resetProgress = await confirm({
title: t("column.resetProgressTitle", "Reset Progress?"),
message: t("column.resetProgressMessage", "Reset all step progress before moving this task?"),
confirmLabel: t("column.resetProgressConfirm", "Reset Progress"),
cancelLabel: t("column.cancelMove", "Cancel Move"),
danger: true,
});
if (!resetProgress) {
return;
}
}
}
await onMoveTask(taskId, column, moveOptions);
} catch (err) {
// Workflow mode (R17): a structured 409 carries a typed rejection. The
// optimistic move snaps back automatically (the next SSE/refresh restores
// the card's real column); surface the translated rejection messageKey.
const rejection = extractTransitionRejection(err);
if (rejection) {
addToast(translateRejection(t, rejection), "error");
} else {
addToast(getErrorMessage(err), "error");
}
}
}, [addToast, allTasks, column, confirm, onMoveTask, tasks, t]);
/*
FNXC:BoardPromote 2026-07-25-04:55:
Promote is a two-attempt flow for the `unplanned-for-execution` rejection only.
The first attempt never forces; if the server refuses because a replan / plan
review is still outstanding, the operator is asked whether to start execution
anyway and the retry carries `{ force: true }`. Every other rejection (capacity,
guard) stays a plain inline message with no override — those are not the
operator's call to waive. Inline feedback rather than a toast, so several holds
can promote concurrently without toast spam.
*/
const handlePromote = useCallback(async (taskId: string) => {
if (!onPromote) return;
setInlineFeedback(null);
setPromotingIds((prev) => {
const next = new Set(prev);
next.add(taskId);
return next;
});
try {
try {
await onPromote(taskId);
} catch (err) {
const rejection = extractTransitionRejection(err);
if (rejection?.code !== "unplanned-for-execution") throw err;
const forceConfirmed = await confirm({
title: t("column.promoteUnplannedTitle", "Start execution anyway?"),
message: t(
"column.promoteUnplannedMessage",
"{{taskId}} is still waiting on planning or plan review. Promoting now starts execution with the current plan and cancels the pending replan.",
{ taskId },
),
confirmLabel: t("column.promoteUnplannedConfirm", "Start Anyway"),
cancelLabel: t("column.promoteUnplannedCancel", "Keep Waiting"),
danger: true,
});
if (!forceConfirmed) {
setInlineFeedback(translateRejection(t, rejection));
return;
}
await onPromote(taskId, { force: true });
}
} catch (err) {
const rejection = extractTransitionRejection(err);
if (rejection) {
setInlineFeedback(translateRejection(t, rejection));
} else {
setInlineFeedback(getErrorMessage(err));
}
} finally {
setPromotingIds((prev) => {
const next = new Set(prev);
next.delete(taskId);
return next;
});
}
}, [confirm, onPromote, t]);
const worktreeGroups = useMemo(() => {
if (!showWorktreeGroups) return [];
return groupByWorktree(tasks, allTasks ?? tasks, maxConcurrent, holdTaskIds);
// `holdTaskIds` IS a dependency: the board resolves it after the workflows fetch, so
// omitting it would pin the first-paint value and the upcoming-work list would keep
// using the legacy-id fallback for the rest of the session. This repo has no
// react-hooks/exhaustive-deps rule, so nothing catches that but reading it.
}, [showWorktreeGroups, tasks, allTasks, maxConcurrent, holdTaskIds]);
const visibleTasks = useMemo(() => {
if (!shouldPaginate) return tasks;
return tasks.slice(0, visibleTaskCount);
}, [shouldPaginate, tasks, visibleTaskCount]);
const hiddenTaskCount = Math.max(0, tasks.length - visibleTasks.length);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-19:45 (Phase B — third attempt, this time with the
fixtures migrated instead of the arm defended):
The `|| column === "triage"` arm was the LEGACY-board path: before workflow lanes, only the
hardcoded intake column offered inline create. U12 deleted the legacy board, Board is Column's
only consumer, and it passes `workflowMode` at all three render sites — so the arm is unreachable
in production.
I deleted it twice before and reverted both times, because four Column tests render without
`workflowMode` and went red. That was the delete-only rule working: a behaviour change means the
branch was not dead FOR THOSE CALLERS. The callers in question are fixtures, not production, so
the honest fix is to migrate them to the shape Board actually uses rather than keep an arm alive
to satisfy them. Done in Column.test.tsx alongside this.
Deliberately NOT solved by defaulting `workflowMode` to true: `isArchived`, `isHoldColumn` and
`isWipProcessingColumn` all switch on that same flag, so a global default would silently
reinterpret every other fixture in the file.
*/
const canCreateInColumn = Boolean(onQuickCreate && !isArchived && workflowMode);
const handleQuickCreate = useCallback(
(input: TaskCreateInput) => {
if (!onQuickCreate) return Promise.resolve();
if (workflowMode) {
/*
FNXC:QuickAddStart 2026-07-22-17:45:
The Quick Add Start intent may carry a workflow-validated initial Todo column.
Preserve that explicit create destination; ordinary Save has no column and continues
to inherit this rendered intake column.
*/
return onQuickCreate({
...input,
column: input.column ?? column,
...(input.workflowId !== undefined ? { workflowId: input.workflowId } : (workflowId ? { workflowId } : {})),
});
}
return onQuickCreate(input);
},
[column, onQuickCreate, workflowId, workflowMode],
);
const handleLoadMore = useCallback(() => {
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
}, [tasks.length]);
/*
FNXC:ArchivePagination 2026-07-08-00:00:
FN-7659 — the Archived column's "Show more" is server-backed (fetches the
next 100-item page ordered `archivedAt DESC` from the API), distinct from
`handleLoadMore` above which only reveals more of an already-fetched
client-side array for non-archived columns.
*/
const [isLoadingMoreArchived, setIsLoadingMoreArchived] = useState(false);
const handleLoadMoreArchived = useCallback(async () => {
if (!onLoadMoreArchived || isLoadingMoreArchived) return;
setIsLoadingMoreArchived(true);
try {
await onLoadMoreArchived();
} finally {
setIsLoadingMoreArchived(false);
}
}, [onLoadMoreArchived, isLoadingMoreArchived]);
const handleReplanAll = useCallback(async () => {
setIsMenuOpen(false);
if (tasks.length === 0) return;
const confirmed = await confirm({
title: t("column.replanAllTitle", "Replan All Tasks"),
message: t("column.replanAllMessage", "Move all {{count}} todo task{{plural}} back to planning to be replanned?", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" }),
});
if (!confirmed) return;
setIsReplanning(true);
try {
// Issue moves in parallel — onMoveTask is per-task, no bulk endpoint.
const results = await Promise.allSettled(
tasks.map((task) => onMoveTask(task.id, "triage" as ColumnType)),
);
const failed = results.filter((r) => r.status === "rejected").length;
const moved = results.length - failed;
if (failed === 0) {
addToast(t("column.movedToPlanning", "Moved {{count}} task{{plural}} to planning for replanning", { count: moved, plural: moved === 1 ? "" : "s" }), "success");
} else {
addToast(t("column.movePartialFailure", "Moved {{moved}} of {{total}} tasks; {{failed}} failed", { moved, total: results.length, failed }), "error");
}
} finally {
setIsReplanning(false);
}
}, [tasks, onMoveTask, addToast, confirm]);
const pauseEligibleTasks = useMemo(
() => tasks.filter((task) => !task.paused && !task.assignedAgentId),
[tasks],
);
const pauseEligibleCount = pauseEligibleTasks.length;
// Bulk-action eligibility (R9): workflow mode keys off trait flags instead of
// the literal column ids. Todo-equivalent = hold/intake (replan affordance);
// processing = wip/countsTowardWip; review = mergeBlocker/humanReview.
const isTodoLikeColumn = workflowMode ? Boolean(columnFlags?.hold || columnFlags?.intake) : column === "todo";
const isProcessingColumn = workflowMode ? Boolean(columnFlags?.countsTowardWip) : column === "in-progress";
const isReviewColumn = workflowMode ? Boolean(columnFlags?.mergeBlocker || columnFlags?.humanReview) : column === "in-review";
const hasColumnBulkActions = isTodoLikeColumn || isProcessingColumn || isReviewColumn;
const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo;
const columnLabelText = workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column];
const handlePauseAll = useCallback(async () => {
if (!onPauseTask) return;
setIsMenuOpen(false);
if (pauseEligibleCount === 0) return;
const confirmed = await confirm({
title: t("column.stopAllTitle", "Stop All Tasks"),
message: t("column.stopAllMessage", "Stop all {{count}} {{columnLabel}} task{{plural}}?", { count: pauseEligibleCount, columnLabel: columnLabelText.toLowerCase(), plural: pauseEligibleCount === 1 ? "" : "s" }),
danger: true,
});
if (!confirmed) return;
setIsPausingAll(true);
try {
const results = await Promise.allSettled(
pauseEligibleTasks.map((task) => onPauseTask(task.id)),
);
const failed = results.filter((r) => r.status === "rejected").length;
const paused = results.length - failed;
if (failed === 0) {
addToast(t("column.stoppedTasks", "Stopped {{count}} task{{plural}}", { count: paused, plural: paused === 1 ? "" : "s" }), "success");
} else {
addToast(t("column.stopPartialFailure", "Stopped {{paused}} of {{total}} tasks; {{failed}} failed", { paused, total: results.length, failed }), "error");
}
} finally {
setIsPausingAll(false);
}
}, [onPauseTask, pauseEligibleCount, columnLabelText, pauseEligibleTasks, addToast, confirm, t]);
const handleMoveAllToTodo = useCallback(async () => {
setIsMenuOpen(false);
if (tasks.length === 0) return;
const confirmed = await confirm({
title: t("column.moveAllToTodoTitle", "Move All to Todo"),
message: t("column.moveAllToTodoMessage", "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", { count: tasks.length, columnLabel: columnLabelText.toLowerCase(), plural: tasks.length === 1 ? "" : "s" }),
});
if (!confirmed) return;
const hasAnyProgress = tasks.some((task) => task.steps.some((step) => step.status !== "pending"));
let preserveProgress = false;
if (hasAnyProgress) {
const keepProgress = await confirm({
title: t("column.preserveProgressTitle", "Preserve Progress?"),
message: t("column.preserveProgressMoveTodoMessage", "Some tasks have completed steps. Keep progress before moving to Todo?"),
confirmLabel: t("column.keepProgress", "Keep Progress"),
cancelLabel: t("column.resetProgress", "Reset Progress"),
});
if (keepProgress) {
preserveProgress = true;
} else {
const resetProgress = await confirm({
title: t("column.resetProgressTitle", "Reset Progress?"),
message: t("column.resetProgressMoveTodoMessage", "Reset step progress for tasks before moving to Todo?"),
confirmLabel: t("column.resetProgressConfirm", "Reset Progress"),
cancelLabel: t("column.cancelMove", "Cancel Move"),
danger: true,
});
if (!resetProgress) {
return;
}
}
}
setIsMovingAllToTodo(true);
try {
const results = await Promise.allSettled(
tasks.map((task) => onMoveTask(task.id, "todo", preserveProgress ? { preserveProgress: true } : undefined)),
);
const failed = results.filter((r) => r.status === "rejected").length;
const moved = results.length - failed;
if (failed === 0) {
addToast(t("column.movedToTodo", "Moved {{count}} task{{plural}} to Todo", { count: moved, plural: moved === 1 ? "" : "s" }), "success");
} else {
addToast(t("column.moveToTodoPartialFailure", "Moved {{moved}} of {{total}} tasks to Todo; {{failed}} failed", { moved, total: results.length, failed }), "error");
}
} finally {
setIsMovingAllToTodo(false);
}
}, [tasks, columnLabelText, onMoveTask, addToast, confirm, t]);
/*
FNXC:DoneColumnSorting 2026-06-29-20:23:
In workflow mode, the Done-sort control belongs to non-archived complete lanes even when the workflow uses a custom column id such as `shipped`; legacy mode remains limited to the literal Done column.
*/
const isDoneSortColumn = workflowMode ? columnFlags?.complete === true && columnFlags?.archived !== true : column === "done";
const showDoneSortControl = isDoneSortColumn && doneSortMode !== undefined && !!onDoneSortModeChange;
const showDoneArchiveAction = isDoneSortColumn && !!onArchiveAllDone;
/*
FNXC:PlanApproval 2026-07-01-08:44:
Triage and workflow intake/planning column actions need a Board shortcut that mirrors the project auto-approve plan override without replacing Settings modal's full workflow/auto-approve/require-all editor.
FNXC:PlanApproval 2026-07-07-00:00 (FN-7653 correction):
This 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 surfacing this toggle. Board.tsx is the single source of truth gating which columns receive `onTogglePlanAutoApprove`; Column.tsx just renders whatever prop it is given, so the fix lives in Board.tsx's intake-only gate, not here.
*/
const hasPlanAutoApproveAction = !!onTogglePlanAutoApprove;
const hasDoneMenuActions = showDoneSortControl || showDoneArchiveAction;
const hasColumnMenu = hasColumnBulkActions || hasDoneMenuActions || hasPlanAutoApproveAction;
const doneSortControlLabel = t("column.doneSortControlLabel", "Sort Done tasks");
const doneSortOptions: Array<{ mode: DoneColumnSortMode; label: string }> = [
{ mode: "completion-date-desc", label: t("column.doneSortCompletionDateDesc", "Completion date (newest first)") },
{ mode: "task-id-desc", label: t("column.doneSortTaskIdDesc", "Task ID (newest first)") },
];
const handleDoneSortModeSelect = useCallback((mode: DoneColumnSortMode) => {
onDoneSortModeChange?.(mode);
setIsMenuOpen(false);
}, [onDoneSortModeChange]);
const handlePlanAutoApproveToggle = useCallback(() => {
onTogglePlanAutoApprove?.();
setIsMenuOpen(false);
}, [onTogglePlanAutoApprove]);
const handleArchiveAll = useCallback(async () => {
setIsMenuOpen(false);
if (!onArchiveAllDone) return;
if (tasks.length === 0) return;
const confirmed = await confirm({
title: t("column.archiveAllTitle", "Archive All Done"),
message: t("column.archiveAllMessage", "Archive all {{count}} done tasks?", { count: tasks.length }),
danger: true,
});
if (!confirmed) return;
try {
const archived = await onArchiveAllDone();
addToast(t("column.archivedTasks", "Archived {{count}} tasks", { count: archived.length }), "success");
} catch (err) {
addToast(getErrorMessage(err) || t("column.failedToArchive", "Failed to archive tasks"), "error");
}
}, [onArchiveAllDone, tasks.length, addToast, confirm, t]);
return (
<div
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
data-column={column}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="column-header">
<div className={`column-dot dot-${column}`} />
<h2>{workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column]}</h2>
<span
className={`column-count${countFlashing ? " count-flash" : ""}`}
aria-label={t("column.executingOfTotal", "{{active}} executing of {{total}}", { active: activeTaskCount, total: tasks.length })}
>
<span>{activeTaskCount}</span>/<span>{tasks.length}</span>
</span>
{(workflowMode ? isReviewColumn : column === "in-review") && onToggleAutoMerge && (
<label className="auto-merge-toggle" title={autoMerge ? t("column.autoMergeEnabled", "Auto-merge enabled") : t("column.autoMergeDisabled", "Auto-merge disabled")}>
{/*
FNXC:AutoMergeA11y 2026-07-14-19:20:
Explicit aria-label keeps the control discoverable as "Auto-merge" for assistive tech and mobile regression tests even when the visible toggle-label is hidden by CSS or i18n wrappers.
*/}
<input
type="checkbox"
checked={!!autoMerge}
onChange={onToggleAutoMerge}
aria-label={t("column.autoMerge", "Auto-merge")}
/>
<span className="toggle-slider" />
<span className="toggle-label">{t("column.autoMerge", "Auto-merge")}</span>
</label>
)}
{onNewTask && (
<button className="btn btn-task-create btn-sm" onClick={onNewTask}>
+ {t("column.newTask", "New Task")}
</button>
)}
{isArchived && onToggleCollapse && (
<button
className="btn btn-icon btn-sm"
onClick={onToggleCollapse}
title={collapsed ? t("column.expandArchivedTitle", "Expand archived tasks") : t("column.collapseArchivedTitle", "Collapse archived tasks")}
aria-label={collapsed ? t("column.expandArchivedLabel", "Expand archived tasks") : t("column.collapseArchivedLabel", "Collapse archived tasks")}
>
{/* Directional chevrons stay explicit for clearer collapsed-state affordance in compact headers. */}
{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
)}
{hasColumnMenu && (
<div className="column-menu" ref={menuRef}>
{/**
FNXC:DoneColumnActions 2026-06-30-00:00:
Done and workflow complete-column archive/sort affordances must share this column actions dropdown with existing bulk actions, preventing duplicate header controls on desktop and mobile while preserving the original sort modes and archive confirmation path.
*/}
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => setIsMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
aria-label={t("column.actionsAriaLabel", "{{columnLabel}} column actions", { columnLabel: columnLabelText })}
title={t("column.actionsTitle", "Column actions")}
disabled={isMenuBusy}
>
<MoreVertical />
</button>
{isMenuOpen && (
<div className="column-menu-popover" role="menu">
{hasPlanAutoApproveAction && (
<label className="column-menu-item auto-merge-toggle" role="menuitemcheckbox" aria-checked={!!planAutoApproveEnabled}>
<span className="column-menu-item-row">
<input
type="checkbox"
checked={!!planAutoApproveEnabled}
onChange={handlePlanAutoApproveToggle}
aria-label={t("column.planAutoApproveLabel", "Auto-approve plan")}
/>
<span className="toggle-slider" aria-hidden="true" />
<span>{t("column.planAutoApproveLabel", "Auto-approve plan")}</span>
</span>
<span className="column-menu-item-hint">
{planAutoApproveEnabled
? t("column.planAutoApproveOnHint", "On bypasses manual plan approval for this project")
: t("column.planAutoApproveOffHint", "Off uses the workflow/default plan approval setting")}
</span>
</label>
)}
{showDoneSortControl && (
<div className="column-menu-group" role="group" aria-label={doneSortControlLabel}>
{doneSortOptions.map((option) => (
<button
key={option.mode}
type="button"
role="menuitemradio"
aria-checked={doneSortMode === option.mode}
className="column-menu-item column-menu-item-radio"
onClick={() => handleDoneSortModeSelect(option.mode)}
>
<span className="column-menu-item-row">
<span className="column-menu-item-check" aria-hidden="true">{doneSortMode === option.mode ? "✓" : ""}</span>
<span>{option.label}</span>
</span>
<span className="column-menu-item-hint">
{option.mode === "completion-date-desc"
? t("column.doneSortCompletionDateDescHint", "Show recently completed tasks first")
: t("column.doneSortTaskIdDescHint", "Show highest task IDs first")}
</span>
</button>
))}
</div>
)}
{showDoneArchiveAction && (
<button
type="button"
role="menuitem"
className="column-menu-item"
onClick={() => void handleArchiveAll()}
disabled={tasks.length === 0}
>
{t("column.archiveAllDoneTitle", "Archive all done tasks")}
<span className="column-menu-item-hint">
{tasks.length === 0
? t("column.noDoneTasksToArchive", "No done tasks to archive")
: t("column.archiveAllDoneHint", "Archive {{count}} done task{{plural}}", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" })}
</span>
</button>
)}
{isTodoLikeColumn && (
<button
type="button"
role="menuitem"
className="column-menu-item"
onClick={() => void handleReplanAll()}
disabled={tasks.length === 0 || isReplanning}
>
{t("column.replanAll", "Replan All")}
<span className="column-menu-item-hint">
{t("column.replanAllHint", "Move {{count}} task{{plural}} to Planning", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" })}
</span>
</button>
)}
{(isProcessingColumn || isReviewColumn) && (
<>
<button
type="button"
role="menuitem"
className="column-menu-item"
onClick={() => void handlePauseAll()}
disabled={pauseEligibleCount === 0 || isPausingAll || !onPauseTask}
>
{t("column.stopAll", "Stop All")}
<span className="column-menu-item-hint">
{tasks.length === 0
? t("column.noTasksInColumn", "No tasks in this column")
: pauseEligibleCount === 0
? t("column.noManuallyPausableTasks", "No manually pausable tasks")
: t("column.pauseHint", "Pause {{count}} active unassigned task{{plural}}", { count: pauseEligibleCount, plural: pauseEligibleCount === 1 ? "" : "s" })}
</span>
</button>
<button
type="button"
role="menuitem"
className="column-menu-item"
onClick={() => void handleMoveAllToTodo()}
disabled={tasks.length === 0 || isMovingAllToTodo}
>
{t("column.moveAllToTodo", "Move All to Todo")}
<span className="column-menu-item-hint">
{t("column.moveToTodoHint", "Move {{count}} task{{plural}} to Todo", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" })}
</span>
</button>
</>
)}
</div>
)}
</div>
)}
</div>
{!isCollapsed && resolvedColumnDescription && (
<p className="column-desc">{resolvedColumnDescription}</p>
)}
{!isCollapsed && inlineFeedback && (
<p className="column-inline-feedback" role="status" data-testid="column-inline-feedback">
{inlineFeedback}
</p>
)}
{!isCollapsed && (
<div className="column-body">
{canCreateInColumn && (
<QuickEntryBox
onCreate={handleQuickCreate}
onMoveTask={onMoveTask}
addToast={addToast}
tasks={allTasks ?? []}
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
workflowId={workflowMode ? workflowId : undefined}
workflowOptions={workflowMode ? workflowOptions : undefined}
defaultWorkflowId={workflowMode ? defaultWorkflowId : undefined}
projectId={projectId}
autoExpand={false}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
onOpenTask={(taskId) => {
const matchingTask = (allTasks ?? []).find((candidate) => candidate.id === taskId);
if (matchingTask) {
onOpenDetail(matchingTask);
return;
}
if (typeof window !== "undefined") {
window.location.hash = `#/tasks/${taskId}`;
}
}}
/>
)}
{showWorktreeGroups ? (
worktreeGroups.length === 0 ? (
<div className="empty-column">{t("column.noTasks", "No tasks")}</div>
) : (
worktreeGroups.map((group) => (
<WorktreeGroup
key={group.label}
label={group.label}
activeTasks={group.activeTasks}
queuedTasks={group.queuedTasks}
projectId={projectId}
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
onPlanningMode={onPlanningMode}
workflowId={workflowMode ? workflowId : undefined}
onMoveTask={onMoveTask}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onPauseTask={onPauseTask}
onRetryTask={onRetryTask}
onUnpauseTask={onUnpauseTask}
onResetTask={onResetTask}
onDuplicateTask={onDuplicateTask}
onMergeTask={onMergeTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
taskCardFieldDefs={taskCardFieldDefs}
taskWorkflowBadges={taskWorkflowBadges}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
mergeStrategy={mergeStrategy}
workflowContextMenuColumns={workflowContextMenuColumns}
taskContextMenuColumnsByTaskId={taskContextMenuColumnsByTaskId}
allTasks={allTasks}
/>
))
)
) : tasks.length === 0 ? (
<div className="empty-column">{t("column.noTasks", "No tasks")}</div>
) : (
<>
{visibleTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
onPlanningMode={onPlanningMode}
planningWorkflowId={workflowMode ? workflowId : taskWorkflowBadges?.get(task.id)?.workflowId ?? null}
onOpenRefine={onOpenRefine}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onPauseTask={onPauseTask}
onRetryTask={onRetryTask}
onUnpauseTask={onUnpauseTask}
onResetTask={onResetTask}
onDuplicateTask={onDuplicateTask}
onMergeTask={onMergeTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
taskColumnFlags={getTaskColumnFlags(task)}
taskMoveColumns={getTaskContextMenuColumns(task)}
onPromote={isHoldColumn && onPromote ? handlePromote : undefined}
isPromoting={isHoldColumn && onPromote ? promotingIds.has(task.id) : undefined}
lastFetchTimeMs={lastFetchTimeMs}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
workflowBadge={taskWorkflowBadges?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
mergeStrategy={mergeStrategy}
nearDuplicateCanonicalInactive={resolveNearDuplicateCanonicalInactive(task)}
/>
))}
{shouldPaginate && hiddenTaskCount > 0 && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleLoadMore}
>
{t("column.loadMore", "Load {{count}} more ({{remaining}} remaining)", { count: Math.min(VISIBLE_TASKS_INCREMENT, hiddenTaskCount), remaining: hiddenTaskCount })}
</button>
)}
{/*
FNXC:ArchivePagination 2026-07-08-00:00:
FN-7659 — the Archived column's "Show more" only renders when the
server reports another page beyond what's currently loaded
(archivedHasMore), so an empty archive or an archive smaller than
one page never shows the button (no empty shell on desktop or
mobile). Distinct from the shouldPaginate/handleLoadMore button
above, which reveals more of an already-fetched client array for
non-archived columns.
*/}
{isArchived && archivedHasMore && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleLoadMoreArchived}
disabled={archivedLoadingMore || isLoadingMoreArchived}
>
{(archivedLoadingMore || isLoadingMoreArchived)
? t("column.loadMoreArchivedLoading", "Loading\u2026")
: t("column.loadMoreArchived", "Show more")}
</button>
)}
</>
)}
<PluginSlot slotId="board-column-footer" projectId={projectId} />
</div>
)}
</div>
);
}
export const Column = memo(ColumnComponent);
Column.displayName = "Column";