diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx
index dbd2e2ccfe..f058129a71 100644
--- a/packages/dashboard/app/components/Column.tsx
+++ b/packages/dashboard/app/components/Column.tsx
@@ -12,6 +12,7 @@ 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";
@@ -441,11 +442,19 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
where the card and the destination differ. Ids remain the fallback for the
no-metadata window.
*/
- const shouldPrompt = hasStepProgress && (
- columnFlags
- ? Boolean(columnFlags.intake || columnFlags.hold)
- : column === "todo" || column === "triage"
- );
+ /*
+ 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) {
@@ -555,11 +564,25 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
}, [shouldPaginate, tasks, visibleTaskCount]);
const hiddenTaskCount = Math.max(0, tasks.length - visibleTasks.length);
- const canCreateInColumn = Boolean(
- onQuickCreate &&
- !isArchived &&
- (workflowMode || column === "triage"),
- );
+ /*
+ 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) => {
diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx
index ea8f6626e7..fc747d2d76 100644
--- a/packages/dashboard/app/components/__tests__/Column.test.tsx
+++ b/packages/dashboard/app/components/__tests__/Column.test.tsx
@@ -810,7 +810,7 @@ describe("Column pagination", () => {
describe("Column QuickEntryBox", () => {
it("renders QuickEntryBox in triage column when onQuickCreate is provided", () => {
const tasks = [makeTask("FN-001")];
- render();
+ render();
expect(screen.getByTestId("quick-entry-box")).toBeTruthy();
});
@@ -828,14 +828,14 @@ describe("Column QuickEntryBox", () => {
it("passes autoExpand={false} to QuickEntryBox in triage column (collapsed by default)", () => {
const tasks = [makeTask("FN-001")];
- render();
+ render();
const quickEntry = screen.getByTestId("quick-entry-box");
expect(quickEntry.getAttribute("data-auto-expand")).toBe("false");
});
it("wires QuickEntry Start moves through the host state-updating callback", async () => {
const onMoveTask = vi.fn().mockResolvedValue(makeTask("FN-created"));
- render();
+ render();
fireEvent.click(screen.getByTestId("quick-entry-move"));
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-created", "todo"));
});
@@ -1331,6 +1331,8 @@ describe("Column same-column drop", () => {
{
,
diff --git a/packages/dashboard/app/utils/taskActivity.ts b/packages/dashboard/app/utils/taskActivity.ts
index 58cea03b84..4f55897912 100644
--- a/packages/dashboard/app/utils/taskActivity.ts
+++ b/packages/dashboard/app/utils/taskActivity.ts
@@ -1,5 +1,6 @@
import type { Task } from "@fusion/core";
import { getUnifiedTaskProgress } from "./taskProgress";
+import { isIntakeColumnRole, isPreImplementationColumnRole } from "./columnRoles";
/** The shared status vocabulary for active task phases and lock/model policy. */
export const ACTIVE_STATUSES = new Set([
@@ -84,9 +85,25 @@ export function isTaskAgentActive(
"intake lane, or a hold lane that is replanning"; without them it falls back to the
ids, which is the same shape the two lanes have today.
*/
+ /*
+ FNXC:WorkflowResolvedColumns 2026-07-30-20:15 (Phase B — one shared predicate):
+ The degraded arm now composes `utils/columnRoles`' predicates instead of naming ids, so the legacy
+ id list lives in exactly one place. Equivalent by construction rather than by inspection:
+
+ intake lane isIntakeColumnRole(undefined, col) -> `triage`
+ hold lane preImplementation AND NOT intake -> `todo`
+
+ which reproduces `col === "triage" || (col === "todo" && isReplanning)` exactly, because the
+ shared pre-implementation set is {todo, triage} and the shared intake id is `triage`.
+
+ Expressed as "not the intake lane" rather than a second id list, so if either shared set changes
+ this composition follows it instead of silently disagreeing with the file next door.
+ */
+ const isLegacyIntakeLane = isIntakeColumnRole(undefined, task.column);
+ const isLegacyHoldLane = isPreImplementationColumnRole(undefined, task.column) && !isLegacyIntakeLane;
const inPlannerLane = options.columnFlags
? options.columnFlags.intake === true || (options.columnFlags.hold === true && isReplanning)
- : task.column === "triage" || (task.column === "todo" && isReplanning);
+ : isLegacyIntakeLane || (isLegacyHoldLane && isReplanning);
const hasFreshPlannerActivity = inPlannerLane
&& Number.isFinite(recentPlannerActivityAtMs)
&& nowMs - recentPlannerActivityAtMs >= 0