From 1de0141ab8034fe02b27cc19dfee4377e6d95135 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 00:13:43 -0700 Subject: [PATCH] fix(dashboard): Task Detail's blocking count read the LEGACY lanes (last of the three fan-out surfaces) (#3004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third and last of the three surfaces calling the blocker fan-out wrapper, completing the sweep started in #2990 (Board + Executor bar). ## What was wrong, precisely The dependent **list** is lane-independent — core pushes `dependentIds` without consulting lanes — so this section looked broadly right. Two things beside it are not: - `overlapBlockedTodoCount`, rendered as **"FN-X is blocking N todo task(s) via blockedBy overlap"** — counted against the literal `todo`, so on a renamed board it read **0 while cards were genuinely blocked**. - the `stale` marker on each blocking dependent — decided against `terminal`/`review` lanes the operator does not use. A wrong number sitting beside a right list is the easiest kind to miss, which is why I checked what the modal actually consumes before deciding this was worth a PR rather than assuming the whole section was broken. ## Why a prop and not a hook This was the surface I deferred in #2990 because it had no trait index in scope. Two options: - `useBoardWorkflows` inside the modal — rejected. The hook documents that it does **not** dedupe across consumers: each call installs its own visibilitychange/focus listeners and its own SSE subscription. That is a new fetch and subscription per modal open, to answer a question the app has already answered. - **Thread the index that already exists** — `App` builds `footerColumnFlagsByTaskId` for the footer; this forwards it through `AppModals` as an optional prop. Chosen. Optional throughout: a card with no entry keeps the documented legacy fallback, so the remote-node case (where local workflow metadata must never be applied to foreign ids) and the pre-load window stay byte-identical. ## Reverted The new case fails on the rendered text — the modal cannot find `"FN-B is blocking 2 todo task(s) via blockedBy overlap"`. The pre-existing legacy-column case above it passes either way, because `todo` satisfies the literal default; that is exactly why it never caught this. ## Verification TaskDetailModal.rendering + ExecutorStatusBar + useBlockerFanout **206 passed** · dashboard app suite 11986 passed / 5 skipped (581 files) · `pnpm test:gate` 161 + 13 + 487 + 71 · lint · census `--strict` · lane-wiring · fnxc-dates · changesets — green. ## One note for whoever owns the FNXC gate `check-fnxc-future-dates.mjs` **rewrites its baseline as a side effect and still exits 0**. Today's date roll dropped 183 stamps out of "future", so any run dirties `scripts/lib/fnxc-future-dates-baseline.json` in the working tree. It cost me a stash conflict before I noticed. Not bundled here — it is repo-wide midnight drift, not this change — but a check that mutates tracked state on a read is worth a look. --- .changeset/taskdetail-fanout-lanes.md | 7 + packages/dashboard/app/App.tsx | 1 + .../dashboard/app/components/AppModals.tsx | 5 + .../app/components/TaskDetailModal.tsx | 21 +- .../TaskDetailModal.rendering.test.tsx | 36 ++++ scripts/lib/fnxc-future-dates-baseline.json | 201 +++--------------- 6 files changed, 93 insertions(+), 178 deletions(-) create mode 100644 .changeset/taskdetail-fanout-lanes.md diff --git a/.changeset/taskdetail-fanout-lanes.md b/.changeset/taskdetail-fanout-lanes.md new file mode 100644 index 0000000000..0a7e6ac530 --- /dev/null +++ b/.changeset/taskdetail-fanout-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Task Detail's "is blocking N todo task(s)" now counts your own lane names instead of reading zero. +category: fix +dev: `TaskDetailContent` takes an optional `columnFlagsByTaskId`, forwarded from `App` through `AppModals`; the fan-out useMemo passes it to the wrapper. Omitted, behaviour is byte-identical. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index aa714be5b3..bef0963ad4 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -2199,6 +2199,7 @@ function AppInner() { ; projects: ProjectInfo[]; currentProject: ProjectInfo | null; addToast: (message: string, type?: ToastType) => void; @@ -103,6 +106,7 @@ interface AppModalsProps { export function AppModals({ projectId, tasks, + columnFlagsByTaskId, projects, currentProject, addToast, @@ -320,6 +324,7 @@ export function AppModals({ task={detailTask} projectId={projectId} tasks={tasks} + columnFlagsByTaskId={columnFlagsByTaskId} onClose={closeDetailWithNav} onOpenDetail={openDetailTaskWithNav} mobileHeaderMode={modalManager.detailTaskOrigin === "list-mobile" ? "back" : "close"} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index e3090889c6..bb466151c3 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -72,7 +72,7 @@ import type { SessionTerminalMode, SessionTerminalPosture } from "./SessionTermi import { usePluginUiSlots } from "../hooks/usePluginUiSlots"; import { appendTokenQuery } from "../auth"; import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete"; -import { MAX_AUTO_MERGE_RETRIES, computeBlockerFanoutMap } from "../hooks/useBlockerFanout"; +import { MAX_AUTO_MERGE_RETRIES, computeBlockerFanoutMap, type BlockerFanoutColumnFlags } from "../hooks/useBlockerFanout"; import { resolveEffectiveGithubRepoDefault } from "./githubTracking"; import type { TFunction } from "i18next"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; @@ -363,6 +363,8 @@ export interface TaskDetailModalProps { task: Task | TaskDetail; projectId?: string; tasks?: Task[]; + /* Per-task lifecycle traits for the blocker fan-out; see the useMemo that consumes it. */ + columnFlagsByTaskId?: ReadonlyMap; onClose: () => void; onOpenDetail: (task: Task | TaskDetail) => void; // For clicking dependencies onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; @@ -722,6 +724,7 @@ export function TaskDetailContent({ task, projectId, tasks = [], + columnFlagsByTaskId, onOpenDetail, onMoveTask, onDeleteTask, @@ -3700,7 +3703,21 @@ export function TaskDetailContent({ return bNum - aNum; }); - const blockerFanoutMap = useMemo(() => computeBlockerFanoutMap(tasks), [tasks]); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-23:20 (third fan-out surface): + Without resolved traits this classified against `todo`/`in-review`/`done`, so on a renamed board + the "blocking N todo task(s)" line counted zero and the `stale` marker on each blocking dependent + was decided against lanes the operator does not use. The dependent LIST itself is lane-independent + (core pushes `dependentIds` unconditionally), which is why the modal still looked broadly right — + only the count and the staleness were wrong. + + Optional: a card with no entry keeps the documented legacy fallback, so the remote-node case and + the pre-load window are byte-identical. + */ + const blockerFanoutMap = useMemo( + () => computeBlockerFanoutMap(tasks, columnFlagsByTaskId ? { columnFlagsByTaskId } : {}), + [tasks, columnFlagsByTaskId], + ); const blockingEntry = blockerFanoutMap.get(task.id); const blockingDependents = useMemo(() => { if (!blockingEntry) return [] as Array<{ id: string; label: string; stale: boolean }>; diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index 6027b278dd..f0127473d5 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -1021,6 +1021,42 @@ describe("TaskDetailModal", () => { expect(screen.getByText("File scope overlap blocker: FN-OVER (stale)")).toBeInTheDocument(); }); + it("counts the overlap blockedBy summary using the board's OWN lane names", () => { + /* + FNXC:WorkflowResolvedColumns 2026-07-30-23:35: + The case below renamed and nothing else. Without resolved traits the count is taken against the + literal `todo`, which no card is in, so this line read "blocking 0 todo task(s)" while two cards + were in fact blocked. The dependent LIST stayed correct throughout (core builds it without + consulting lanes), which is what made the wrong number easy to miss. + */ + const tasks = [ + makeTask({ id: "FN-B", column: "building" }), + makeTask({ id: "FN-1", column: "drafting", blockedBy: "FN-B" }), + makeTask({ id: "FN-2", column: "drafting", blockedBy: "FN-B" }), + ]; + const columnFlagsByTaskId = new Map(tasks.map((task) => [ + task.id, + task.column === "building" ? { countsTowardWip: true } : { hold: true }, + ])); + + render( + , + ); + + expect(screen.getByText("FN-B is blocking 2 todo task(s) via blockedBy overlap")).toBeInTheDocument(); + }); + it("shows overlap blockedBy summary in Blocking section", () => { render(