Phase B — TaskContextMenu.tsx: intake by ROLE (2 → 1), and two conversions I dropped rather than force (#2626)

**Claimed:** `packages/dashboard/app/components/TaskContextMenu.tsx`

| file | before | after |
|---|---:|---:|
| `TaskContextMenu.tsx` | **2** | **1** |

## The real bug

`shouldShowActionsMenu: task.column !== "triage"` meant *"a bare card in
a pure intake lane has no actions worth showing yet."*

Post-U11 the literal does not go dead — it **inverts**. A default
Planning card is `todo`, so the condition is true and the menu shows
unconditionally. That is right for the hold half (cards waiting for
capacity do have actions), but the guard has stopped distinguishing
anything — and it would show a full action menu on a bare Coding (Ideas)
capture, which is the case it existed to suppress.

Resolved to `intake AND NOT hold` — a *pure* intake lane — which
reproduces all four shapes rather than picking a winner:

| workflow | column traits | menu |
|---|---|---|
| legacy `triage` | intake only | suppressed *(as before)* |
| legacy `todo` | hold only | shown *(as before)* |
| merged Planning | intake + hold | shown *(matches the Todo half, where
cards wait)* |
| Ideas `ideas` | intake only | suppressed *(a bare captured idea)* |

Its degraded arm now defers to `isIntakeColumnRole`, so the legacy
intake id lives in `utils/columnRoles.ts` only.

## The remaining site is audited, not overlooked

I routed `isPreExecutionHoldColumn` through
`isPreImplementationColumnRole` — same question, one definition — **and
then reverted it.** Its degraded-mode answer is wider: its legacy set is
`{todo, triage}`, this predicate's was `{triage}` alone.

They differ **for a reason.** That helper drives the preserve-progress
prompt, where a flagless `todo` *should* prompt because losing steps is
unrecoverable. This one drives the Plan affordance, where a flagless
`todo` must **not** offer to re-plan a card that may already be planned.

Consolidating added `plan` to flagless `todo` cards — caught by
*"exposes Plan only for pre-execution hold columns"*. Identical trait
path, non-interchangeable fallbacks. Kept separate with the difference
recorded rather than made to look shared.

## Two conversions I dropped rather than force

**1. A `ListView.tsx` 5 → 0 conversion.** Main changed underneath it:
the U12 worker centralized the same fallbacks into
`utils/columnRoles.ts`. Their approach is on main and other files
already call it, so I took theirs and dropped mine rather than fight for
my version through a rebase conflict.

**2. A `strandedColumnFlags.ts` seam** that resolved an undeclared
column's role from the workflow's **rebound target**, so the degraded
arms could be *deleted* rather than documented.

I built it, tested it, wired it into ListView — and then their
`columnRoles.ts` identified a state my seam cannot serve: the **pre-load
window**, where the board renders before the workflows fetch resolves
and there are no columns at all, hence no rebound target to borrow from.
Their analysis is more complete than mine, the fallback is genuinely
undeletable, and shipping an unused module is worse than shipping
nothing.

Worth recording because I twice reported these arms as permanently
unconvertible, then thought I had a way to convert them, and was wrong
for a reason worth knowing: **there are two degraded states, not one.**
The stranded-card half is resolvable; the pre-load half is not.

## Verification

10 of 11 green in this suite. The one failure — `"Back to in-progress"`
vs `"Back to In Progress"` — is **pre-existing**, verified by stashing
this change and re-running against clean `main`.

Dashboard app typecheck and lint clean.

🤖 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 23:03:26 -07:00
committed by GitHub
parent 1d0f21b428
commit 347107d8e4
2 changed files with 88 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ import { Fragment, useCallback, useEffect, useRef } from "react";
import type { TFunction } from "i18next";
import type { ColumnId, Task, TaskDetail, WorkflowStepResult } from "@fusion/core";
import { VALID_TRANSITIONS, isColumn } from "@fusion/core";
import { isIntakeColumnRole } from "../utils/columnRoles";
// `COLUMNS` is gone from this file: deleting the default-column-set shortcut removed
// the last use. `VALID_TRANSITIONS` survives ONLY for the no-metadata load window (see
// the note at `moveTransitions`); every workflow-resolved path now reads the payload's
@@ -147,8 +148,34 @@ function isMutableLiveColumn(column: string, flags?: TaskContextMenuColumnFlags)
return column !== "done" && column !== "archived";
}
/**
* A PURE intake lane — intake without hold. A merged Planning column carries both, so it is not
* "pure intake": cards rest there waiting for capacity and have real actions.
*/
function isPureIntakeColumn(column: string, flags?: TaskContextMenuColumnFlags): boolean {
// With traits, "pure" means intake WITHOUT hold — a merged Planning column carries both and is
// therefore not pure. Without traits, defer to the shared intake role so the degraded-mode id
// list lives in exactly one place.
if (flags) return flags.intake === true && flags.hold !== true;
return isIntakeColumnRole(undefined, column);
}
export function isPreExecutionHoldColumn(column: string, flags?: TaskContextMenuColumnFlags): boolean {
if (flags?.complete === true || flags?.archived === true) return false;
/*
FNXC:WorkflowResolvedColumns 2026-07-30-18:35 (Phase B — AUDITED, deliberately NOT consolidated):
`isPreImplementationColumnRole` in `utils/columnRoles.ts` answers a near-identical question and I
routed this through it — then reverted, because its DEGRADED-MODE answer is wider than this one's.
Its legacy set is {todo, triage}; this predicate's was {triage} alone. They differ for a reason:
that helper drives the preserve-progress prompt, where a flagless `todo` should prompt (losing
steps is unrecoverable), while THIS drives the Plan affordance, where a flagless `todo` must not
offer to re-plan a card that may already be planned. Consolidating added `plan` to flagless `todo`
cards — caught by "exposes Plan only for pre-execution hold columns".
Same shape, different degraded answer: the trait path is identical and the fallbacks are not
interchangeable. Kept separate with the difference recorded, rather than made to look shared.
*/
return column === "triage" || flags?.intake === true || flags?.hold === true;
}
@@ -448,8 +475,25 @@ export function buildTaskActionMenuModel(options: BuildTaskActionMenuModelOption
actions,
moveTransitions: getTaskMoveTransitions(task, t, columnLabel, workflowMoveColumns),
reviewAction: getTaskReviewAction(task, options),
/*
FNXC:WorkflowResolvedColumns 2026-07-30-15:25 (Phase B — TaskContextMenu.tsx):
Was `task.column !== "triage"`. The intent is "a bare card sitting in a pure INTAKE lane has no
actions worth showing yet" — `triage` happened to be that lane, and `todo` (hold) always showed
the menu because cards waiting for capacity have real actions.
Post-U11 the literal inverts: a default Planning card is `todo`, so `!== "triage"` is true and
the menu shows unconditionally — which is right for the hold half, but the guard has stopped
distinguishing anything and would also show a full menu on a bare Coding (Ideas) capture.
Resolved to `intake AND NOT hold` — a PURE intake lane — which reproduces every shape:
legacy `triage` intake only -> suppressed (as before)
legacy `todo` hold only -> shown (as before)
merged Planning intake + hold -> shown (matches the Todo half, where cards wait)
Ideas `ideas` intake only -> suppressed (a bare captured idea)
Falls back to the legacy id when no flags are supplied, so unwired menu hosts are unchanged.
*/
shouldShowActionsMenu:
task.column !== "triage" ||
!isPureIntakeColumn(task.column, currentColumnFlags) ||
task.status === "awaiting-approval" ||
canRetryTask ||
isTaskPaused ||

View File

@@ -333,3 +333,46 @@ describe("TaskContextMenu shared task action model", () => {
expect(pause).toHaveFocus();
});
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-10:20 (PR #2626 review — greptile P2):
The intake-only vs intake+hold distinction, covered per workflow SHAPE rather than per column id.
`shouldShowActionsMenu` suppresses the menu only for a PURE intake lane. That distinction is the
whole point of the conversion and it was asserted only through the legacy `triage` id, which cannot
express either post-U11 shape: a merged Planning column carries both traits, and Coding (Ideas)
carries intake alone on a non-legacy id. A later predicate change could restore the unwanted Ideas
menu or hide actions on Planning cards with nothing failing.
*/
describe("shouldShowActionsMenu by workflow shape (not by column id)", () => {
const model = (column: string, flags: Record<string, boolean>) =>
buildTaskActionMenuModel({
task: makeTask({ column: column as never }),
t,
columnLabel: columnLabel as never,
currentColumnFlags: flags as never,
}).shouldShowActionsMenu;
it("SUPPRESSES the menu on a Coding (Ideas) capture — intake with no hold, non-legacy id", () => {
// `ideas` is not the legacy intake id, so only the trait can answer. A bare captured idea has
// no actions worth offering yet, which is the case the original guard existed for.
expect(model("ideas", { intake: true })).toBe(false);
});
it("SHOWS the menu on a merged Planning column — intake AND hold", () => {
// Post-U11 the default Planning column carries both traits. Cards rest here waiting for
// capacity and do have real actions, so suppressing would remove affordances that existed
// when this was the separate `todo` lane.
expect(model("todo", { intake: true, hold: true })).toBe(true);
});
it("SHOWS the menu on a hold-only lane, as the pre-merge `todo` column did", () => {
expect(model("todo", { hold: true })).toBe(true);
});
it("SUPPRESSES on a RENAMED pure-intake lane, proving no id is consulted", () => {
// The assertion that fails if anyone reintroduces an id comparison: `backlog` matches no
// legacy literal, so a correct answer here can only come from the trait.
expect(model("backlog", { intake: true })).toBe(false);
});
});