From 890d58889160c00a305f392529151b4f4b4995c0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 29 Jul 2026 22:37:43 -0700 Subject: [PATCH] =?UTF-8?q?Phase=20B=20=E2=80=94=20Column.tsx=202=E2=86=92?= =?UTF-8?q?0=20and=20taskActivity.ts=202=E2=86=920=20(U11's=20cluster=20to?= =?UTF-8?q?=20zero)=20(#2636)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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) --- packages/dashboard/app/components/Column.tsx | 43 ++++++++++++++----- .../app/components/__tests__/Column.test.tsx | 10 +++-- packages/dashboard/app/utils/taskActivity.ts | 19 +++++++- 3 files changed, 58 insertions(+), 14 deletions(-) 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