Phase B — Column.tsx 2→0 and taskActivity.ts 2→0 (U11's cluster to zero) (#2636)

**Claimed:** `Column.tsx`, `taskActivity.ts` — both to zero.

| file | before | after |
|---|---:|---:|
| `packages/dashboard/app/components/Column.tsx` | **2** | **0** |
| `packages/dashboard/app/utils/taskActivity.ts` | **2** | **0** |

## `Column.tsx` — two different fixes, because the two sites are
different problems

**The preserve-progress prompt** routed through
`isPreImplementationColumnRole`. This is the *same* question that helper
was written for — ListView asks it about a move target, Column asks it
about itself — and the degraded id sets are identical (`{todo,
triage}`), so the consolidation is exact.

I verified the sets matched **before** consolidating, because the
sibling case is not interchangeable: `isPreExecutionHoldColumn` in
`TaskContextMenu` drives the Plan affordance and its degraded set is
`{triage}` alone. Routing *that* through this helper added `plan` to
flagless `todo` cards, caught by an existing test. **Same shape,
identical trait path, non-interchangeable fallbacks.**

**The legacy-board arm** (`workflowMode || column === "triage"`) —
deleted, on the third attempt.

I deleted it twice before and reverted both times because four Column
tests render without `workflowMode`. That was the delete-only rule
working, but **my conclusion from it was wrong**: a behaviour change
means the branch was not dead *for those callers*, and the callers are
**fixtures, not production**. Board is Column's only consumer and passes
`workflowMode` at all three render sites. Defending an unreachable arm
so four tests keep passing preserves the tests, not the behaviour.

Two notes for anyone converting the remaining dashboard files:

- I did **not** default `workflowMode` to `true`, which was the tempting
one-liner. `isArchived`, `isHoldColumn` and `isWipProcessingColumn` all
switch on that same flag, so a global default would silently reinterpret
every other fixture in an 85-test file.
- **"Four tests break" was itself an underestimate.** Two more FN-770
fixtures surfaced only after the first two were fixed, because they
render their own explicit `column="triage"` block instead of using
`defaultProps`. The blast radius only became accurate by fixing it in
waves.

## `taskActivity.ts` — composed, not copied

The degraded arm now composes `utils/columnRoles`' predicates instead of
naming ids. **No local copy** — which is the failure mode #2625 hit from
the other direction.

Equivalent *by construction*:

| lane | composition | resolves to |
|---|---|---|
| intake | `isIntakeColumnRole(undefined, col)` | `triage` |
| hold | `isPreImplementationColumnRole(...)` **and not** intake |
`todo` |

reproducing `col === "triage" || (col === "todo" && isReplanning)`
exactly, since the shared pre-implementation set is `{todo, triage}` and
the shared intake id is `triage`.

Deliberately phrased as *"pre-implementation and not intake"* rather
than a second id list: if either shared set changes, this composition
follows it instead of silently disagreeing with the file next door. That
disagreement is precisely what bit the `TaskContextMenu` consolidation
above.

**I previously reported this site as blocked on `TaskCard.tsx` (U12's)**
— on the theory that the arm could only die once every caller supplied
resolved flags. Wrong framing: the arm doesn't need to become
*unreachable*, it needs to stop *naming ids*. Composing the shared
predicates does that without touching any caller.

## Verification

**1139 of 1141** green across `app/utils`, `Column` and `TaskCard`
suites. The two `TaskCard` failures are **pre-existing** — verified by
stashing this change and re-running, where they fail identically.
Dashboard app typecheck and lint clean.

Takes U11's cluster to zero except `TaskContextMenu.tsx`, whose
remaining site is covered in **#2626** and whose second site is a
documented non-consolidation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-29 22:37:43 -07:00
committed by GitHub
parent 6ca7cc94ec
commit 890d588891
3 changed files with 58 additions and 14 deletions

View File

@@ -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) => {

View File

@@ -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(<Column {...defaultProps} tasks={tasks} onQuickCreate={vi.fn()} />);
render(<Column {...defaultProps} workflowMode columnFlags={{ intake: true }} tasks={tasks} onQuickCreate={vi.fn()} />);
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(<Column {...defaultProps} tasks={tasks} onQuickCreate={vi.fn()} />);
render(<Column {...defaultProps} workflowMode columnFlags={{ intake: true }} tasks={tasks} onQuickCreate={vi.fn()} />);
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(<Column {...defaultProps} tasks={[]} onQuickCreate={vi.fn()} onMoveTask={onMoveTask} />);
render(<Column {...defaultProps} workflowMode columnFlags={{ intake: true }} tasks={[]} onQuickCreate={vi.fn()} onMoveTask={onMoveTask} />);
fireEvent.click(screen.getByTestId("quick-entry-move"));
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-created", "todo"));
});
@@ -1331,6 +1331,8 @@ describe("Column same-column drop", () => {
<Column
{...defaultProps}
column="triage"
workflowMode
columnFlags={{ intake: true }}
tasks={[]}
onQuickCreate={vi.fn().mockResolvedValue({})}
favoriteProviders={["anthropic"]}
@@ -1352,6 +1354,8 @@ describe("Column same-column drop", () => {
<Column
{...defaultProps}
column="triage"
workflowMode
columnFlags={{ intake: true }}
tasks={[]}
onQuickCreate={vi.fn().mockResolvedValue({})}
/>,

View File

@@ -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