Files
fusion/packages/dashboard/app/hooks/useTaskDiffStats.ts
gsxdsm 2c24966d0c fleet: the app-side remainder 18 → 0 — Archive/Revert and diff stats were silently absent on a renamed board (#2731)
Three **genuinely free** clusters in one layer and one idiom —
`Column.tsx` (7), `ListView.tsx` (6), `useTaskDiffStats.ts` (5). I built
the claimed-file set from every open PR's diff before starting, having
duplicated a claimed cluster last round.

## Census

| file | before | after |
|---|---:|---:|
| `Column.tsx` | 7 | **0** |
| `ListView.tsx` | 6 | **0** |
| `useTaskDiffStats.ts` | 5 | **0** |

**16 converted; 2 reclassified with a reason** — the two are accounted
for separately below so the numbers stay honest.

## Three silent failures, not three style nits

- **`ListView` Archive and Revert** were gated on `task.column ===
"done"` / `=== "archived"`, so on a board with renamed terminal lanes
**they did not render at all**. No error, no log — the operator simply
cannot archive or revert from the list.
- **`useTaskDiffStats`** compared a bare `column: string` to
`done`/`in-progress`/`in-review`, so on a renamed board it **fetched
nothing** and the row showed no changes.
- **`ListView` progress display** had the same shape for the WIP lane.

## The `?? {}` is the whole subtlety

Every `Column.tsx` site was `workflowMode ? <trait> : column ===
"<id>"`. One adapter now feeds the shared helpers:

```ts
const columnRoleFlags = workflowMode ? (columnFlags ?? {}) : undefined;
```

`workflowMode` means **traits are the only authority**, so a
workflow-mode column with no resolved flags must answer `false` — which
`Boolean(columnFlags?.archived)` did. Passing `undefined` to a role
helper instead selects its **legacy id fallback**, so a flagless
workflow-mode column would start matching on its id. An empty object
keeps the helper on its trait branch. Legacy mode passes `undefined`
deliberately: there the id fallback *is* the answer, and routing it
through the helpers is the point.

## Two things I deliberately did not do

**`isTodoLikeColumn` keeps its own trait arm.** Adopting
`isPreImplementationColumnRole` would widen its fallback from `todo`
alone to `{todo, triage}`, handing a legacy `triage` column a bulk
replan affordance it does not have today — a behaviour change hiding
inside a de-duplication. Only its *fallback* is routed through a helper.

**The `mode === "done"` pair is reclassified, not converted.** It is the
hook's own `"done" | "active"` discriminant, assigned three lines from
`shouldFetchDoneTask` — not a column id, with no trait to resolve. The
census counts it because the receiver is compared to the string `done`,
which is a classifier limit. Marked deliberate and **recorded in
`deliberateByFile`**, so that file's `byFile` drop is 5 while its
conversion count is 3.

One genuine simplification fell out: `workflowMode ? isReviewColumn :
column === "in-review"`, where `isReviewColumn` is *itself* that same
ternary. Both arms already agreed with it — collapsing is
behaviour-identical.

## Revert proof

Restoring the id comparisons on the ListView row menu fails the new
renamed-lane case with `Unable to find an accessible element with the
role "menuitem" and name "Archive"`.

Driven through the **real `fetchBoardWorkflows` seam** with a renamed
vocabulary — payload → `listColumns` → `columnFlagsById` → row menu —
rather than by injecting flags, so the assertion covers the path the
component actually uses. The DEFAULT-vocabulary path passes either way,
which is exactly why the renamed case has to exist.

## Verification

`pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **375 passed** across
Column / ListView / useTaskDiffStats / role-invariance / columnRoles ·
dashboard `tsc -p tsconfig.app.json` clean · `pnpm lint` clean · census
`--strict` exits 0.

`TaskCard.tsx` is touched only to pass the new optional `columnFlags`
through; its own census count is unchanged at 3. The 2 `TaskCard` reds
in that suite are the known pre-existing CSS-var geometry assertions.

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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved workflow lane handling when columns are renamed or assigned
roles through workflow settings.
* Archive and Revert actions now remain available for completed and
archived tasks in renamed lanes.
* Corrected task progress and diff-stat behavior across active, review,
completed, and archived lanes.
* Updated bulk actions, sorting controls, and auto-merge controls to
respond consistently to workflow roles.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

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

228 lines
8.9 KiB
TypeScript

import { useEffect, useState } from "react";
import { fetchTaskDiff } from "../api";
import type { ColumnRoleFlags } from "../utils/columnRoles";
import { isCompleteColumnRole, isReviewColumnRole, isWipColumnRole } from "../utils/columnRoles";
interface DiffStats {
filesChanged: number;
additions: number;
deletions: number;
}
interface UseTaskDiffStatsResult {
stats: DiffStats | null;
loading: boolean;
}
interface UseTaskDiffStatsOptions {
/*
FNXC:WorkflowResolvedColumns 2026-07-30-03:30 (fleet phase):
Resolved trait flags for the task's column, so "is this done / still working" is a ROLE question. The
hook took a bare `column: string` and compared it to `done` / `in-progress` / `in-review`, which on a
renamed board fetched NOTHING — the diff stats silently never loaded and the row showed no changes.
OPTIONAL, and the helpers fall back to the legacy ids without it, so the ten existing test call sites
and any caller that has no flags keep their current behaviour. The one production caller (TaskCard)
already had `taskColumnFlags` in scope.
*/
columnFlags?: ColumnRoleFlags;
/** Enable fetching when true (default). Suppresses fetches for offscreen cards. */
enabled?: boolean;
/** Worktree path for active task columns. */
worktree?: string;
/** Version identifier that changes when steps update. Forces cache invalidation when changed. */
stepVersion?: number | string;
/**
* Done-task merge enrichment signature (e.g. landedFiles length + filesChanged).
* For done cards this invalidates cache/refetches when mergeDetails enrichment lands,
* analogous to stepVersion invalidation for active columns.
*/
mergeSignature?: number | string;
/** Poll interval in ms for active columns (in-progress, in-review). Forces re-fetch bypassing cache. */
pollIntervalMs?: number;
}
/**
* Cache for diff stats to avoid repeated fetches during rerenders.
* Key format: "taskId:projectId"
* Entries expire after the TTL to ensure freshness.
*/
const diffStatsCache = new Map<string, { stats: DiffStats; expiresAt: number }>();
const CACHE_TTL_MS = 30_000; // 30 seconds
function getCacheKey(taskId: string, projectId?: string, worktree?: string, stepVersion?: string, mode?: "done" | "active"): string {
return `${taskId}:${projectId ?? ""}:${worktree ?? ""}:${stepVersion ?? ""}:${mode ?? ""}`;
}
function getCachedStats(taskId: string, projectId?: string, worktree?: string, stepVersion?: string, mode?: "done" | "active"): DiffStats | null {
const key = getCacheKey(taskId, projectId, worktree, stepVersion, mode);
const entry = diffStatsCache.get(key);
if (!entry) return null;
// Check expiration
if (Date.now() > entry.expiresAt) {
diffStatsCache.delete(key);
return null;
}
return entry.stats;
}
function setCachedStats(taskId: string, projectId: string | undefined, worktree: string | undefined, stepVersion: string | undefined, mode: "done" | "active", stats: DiffStats): void {
const key = getCacheKey(taskId, projectId, worktree, stepVersion, mode);
diffStatsCache.set(key, {
stats,
expiresAt: Date.now() + CACHE_TTL_MS,
});
}
/**
* Clears all entries from the diff stats cache.
* Exported for testing purposes.
*/
export function __test_clearDiffStatsCache(): void {
diffStatsCache.clear();
}
/**
* Fetches diff stats for a task's Changes tab.
*
* For active worktree-backed tasks, this keeps the TaskCard count aligned with
* the Changes tab. For done tasks, this hook's `stats.filesChanged` is the
* authoritative lineage-union total from `/api/tasks/:id/diff`, not the final
* merge commit's shortstat.
* Per FN-4527/FN-4647, consumers must not fall back to
* `task.mergeDetails.filesChanged` once `loading` is false and `stats` is null:
* stored mergeDetails counts can be stale after post-merge rebase-and-push
* flows (see FN-4526).
*
* @param taskId - Task identifier
* @param column - Current task column
* @param commitSha - Merge commit SHA (undefined = no merge yet)
* @param projectId - Optional project identifier
* @param options.enabled - When false, no fetch is made and returns empty/stable state
* @param options.mergeSignature - Done-mode invalidation signal derived from mergeDetails enrichment
*/
export function useTaskDiffStats(
taskId: string,
column: string,
commitSha: string | undefined,
projectId?: string,
options: UseTaskDiffStatsOptions = {},
): UseTaskDiffStatsResult {
const enabled = options.enabled ?? true;
const worktree = options.worktree;
const stepVersion = options.stepVersion;
const pollIntervalMs = options.pollIntervalMs;
const mergeSignature = options.mergeSignature;
const columnFlags = options.columnFlags;
/*
FNXC:WorkflowResolvedColumns 2026-07-30-12:15 (PR #2731 review — coderabbit, and I dismissed this
twice before checking):
DERIVED OUTSIDE THE EFFECT SO THEY CAN BE DEPENDENCIES. `columnFlags` arrives from a board-workflows
fetch, so it is `undefined` on first paint and populated later. The effect read it but the dependency
array did not list it, so the poll kept the PRE-RESOLUTION answer: on a renamed board a card in a
custom complete/wip/review lane never started fetching diff stats at all.
The booleans rather than the object: `columnFlags` is a prop object whose identity a parent may change
every render, which would restart the poll continuously. These are primitives, so they change exactly
when the answer changes — which is the dependency the effect actually has.
*/
const shouldFetchDoneTask = isCompleteColumnRole(columnFlags, column);
const shouldFetchActiveTask = isWipColumnRole(columnFlags, column)
|| isReviewColumnRole(columnFlags, column);
const [stats, setStats] = useState<DiffStats | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
// Disabled state: return stable empty state without fetching
if (!enabled) {
setStats(null);
setLoading(false);
return;
}
if (!taskId || (!shouldFetchDoneTask && !shouldFetchActiveTask)) {
setStats(null);
setLoading(false);
return;
}
const activeWorktree = shouldFetchActiveTask ? worktree : undefined;
const stepVersionStr = stepVersion !== undefined ? String(stepVersion) : undefined;
const mergeSignatureStr = mergeSignature !== undefined ? String(mergeSignature) : undefined;
const mode: "done" | "active" = shouldFetchDoneTask ? "done" : "active";
let cancelled = false;
async function load(forceRefresh = false) {
// Check cache first - return immediately without loading flicker (unless force refresh)
if (!forceRefresh) {
/*
FNXC:WorkflowResolvedColumns 2026-07-30-03:30 DELIBERATE-LITERAL:
`mode` is this function's OWN `"done" | "active"` discriminant, assigned three lines up from
`shouldFetchDoneTask`. It is not a column id and there is no trait to resolve — the census
classifies it as a column guard because the receiver is compared to the string `done`, which is
a classifier limitation, not a site to convert.
*/
const cacheVersion = mode === "done" ? mergeSignatureStr : stepVersionStr;
const cached = getCachedStats(taskId, projectId, activeWorktree, cacheVersion, mode);
if (cached) {
if (!cancelled) {
setStats(cached);
setLoading(false);
}
return;
}
}
setLoading(true);
try {
const data = await fetchTaskDiff(taskId, activeWorktree, projectId);
if (!cancelled) {
setStats(data.stats);
// Store in cache
/*
FNXC:WorkflowResolvedColumns 2026-07-30-03:30 DELIBERATE-LITERAL:
`mode` is this function's OWN `"done" | "active"` discriminant, assigned three lines up from
`shouldFetchDoneTask`. It is not a column id and there is no trait to resolve — the census
classifies it as a column guard because the receiver is compared to the string `done`, which is
a classifier limitation, not a site to convert.
*/
const cacheVersion = mode === "done" ? mergeSignatureStr : stepVersionStr;
setCachedStats(taskId, projectId, activeWorktree, cacheVersion, mode, data.stats);
}
} catch {
if (!cancelled) {
setStats(null);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
// Initial fetch
void load();
// Set up polling for active columns
let timer: ReturnType<typeof setInterval> | undefined;
if (pollIntervalMs && shouldFetchActiveTask) {
timer = setInterval(() => {
// Force refresh on poll - bypass cache
void load(true);
}, pollIntervalMs);
}
return () => {
cancelled = true;
if (timer) {
clearInterval(timer);
}
};
}, [taskId, column, commitSha, projectId, enabled, worktree, stepVersion, mergeSignature, pollIntervalMs, shouldFetchDoneTask, shouldFetchActiveTask]);
return { stats, loading };
}