diff --git a/.changeset/taskcard-live-ticker-renamed-lanes.md b/.changeset/taskcard-live-ticker-renamed-lanes.md new file mode 100644 index 0000000000..c159568d91 --- /dev/null +++ b/.changeset/taskcard-live-ticker-renamed-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Cards in renamed in-progress or review lanes now show their live elapsed-time indicator. +category: fix +dev: `wantsLiveTimeIndicator`'s dependency list omitted `isWipColumn`/`isReviewColumn`/`taskColumnFlags`, so the memo kept the pre-load answer computed before workflow traits resolved. diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 8294cec3eb..141731ae6e 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -1732,7 +1732,24 @@ function TaskCardComponent({ } return true; - }, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.firstExecutionAt, task.cumulativeActiveMs, task.executionStartedAt, task.executionCompletedAt]); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-23:40: + THE LANE ROLES BELONG IN THIS LIST, or the card never subscribes on a renamed board. + + `isWipColumn`/`isReviewColumn` derive from the `taskColumnFlags` PROP, which arrives after first + paint — the board resolves workflow traits asynchronously. The first computation therefore runs + with the flags undefined, the role helpers fall back to the legacy ids, and on a renamed board that + answers false. When the flags arrive `task.column` is unchanged, so a list of `task.*` fields alone + never recomputes and the pre-load answer sticks: no live elapsed-time indicator, for the life of + the mount. + + A legacy board hid this completely, because there the fallback already answers true on the first + paint and the stale list costs nothing. + + This repo has no `react-hooks/exhaustive-deps` rule, so the list is maintained by hand and a + disable directive for that rule fails CI. + */ + }, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.firstExecutionAt, task.cumulativeActiveMs, task.executionStartedAt, task.executionCompletedAt, isWipColumn, isReviewColumn, taskColumnFlags]); const timeIndicatorNowMs = useLiveTimeTicker(wantsLiveTimeIndicator); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.live-ticker-renamed-lanes.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.live-ticker-renamed-lanes.test.tsx new file mode 100644 index 0000000000..b1b3e66d70 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskCard.live-ticker-renamed-lanes.test.tsx @@ -0,0 +1,113 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-30-23:35: +THE LIVE ELAPSED-TIME INDICATOR NEVER STARTED FOR CARDS IN A RENAMED WIP OR REVIEW LANE. + +`wantsLiveTimeIndicator` decides whether a card subscribes to the shared ticker. It reads +`isWipColumn` / `isReviewColumn` / `taskColumnFlags` — all derived from the `taskColumnFlags` PROP — +while its dependency array lists only `task.*` fields. + +`taskColumnFlags` arrives ASYNCHRONOUSLY: the board resolves workflow traits after first paint, so +the first computation always runs with the flags undefined. The role helpers then fall back to the +legacy ids, and on a renamed board `isWipColumnRole(undefined, "building")` is false. The card +decides it needs no ticker. When the flags arrive, `task.column` has not changed — so nothing in the +dependency array changed, the memo never recomputes, and the card never subscribes. + +WHY THE DEFAULT BOARD HID IT. With legacy ids the fallback answers `true` on the very first paint +(`column === "in-progress"`), so the memo's initial value is already correct and the stale dependency +list costs nothing. The defect is renamed-board-only, which is why it survived. + +Found by generalizing the memo-dependency defect in the blocker fan-out (#2993) into a sweep for +memoized hooks that read a lane value absent from their deps, rather than treating that one as a +one-off. This repo has no `react-hooks/exhaustive-deps` rule, so the whole class is invisible to lint. +*/ + +import { describe, it, expect, vi } from "vitest"; +import { render } from "@testing-library/react"; +import type { Task } from "@fusion/core"; +import { TaskCard } from "../TaskCard"; + +/* The subscription flag is the observable: `enabled` is exactly `wantsLiveTimeIndicator`. */ +const tickerCalls: boolean[] = []; +vi.mock("../../hooks/useLiveTimeTicker", () => ({ + useLiveTimeTicker: (enabled: boolean) => { + tickerCalls.push(enabled); + return Date.now(); + }, +})); + +const noop = () => {}; + +function taskIn(column: string): Task { + return { + id: "KB-1", + title: "a card", + description: "t", + column, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + executionStartedAt: "2026-06-01T00:00:00.000Z", + cumulativeActiveMs: 60_000, + steps: [], + } as unknown as Task; +} + +/** Did the card subscribe on its LATEST render? */ +const subscribedNow = () => tickerCalls[tickerCalls.length - 1] === true; + +describe("the live time indicator when column traits arrive after first paint", () => { + /* Control: on a legacy board the fallback answers correctly on the first paint, so this passes + with or without the fix and proves the harness observes a real subscription. */ + it("default vocabulary: a card in `in-progress` subscribes with no flags at all", () => { + tickerCalls.length = 0; + render(); + + expect(subscribedNow()).toBe(true); + }); + + /* + The defect. First paint has no flags, so the renamed lane reads as not-WIP and the card declines + the ticker; the flags then arrive and nothing recomputes. + */ + it("renamed vocabulary: a card subscribes once its WIP traits arrive", () => { + tickerCalls.length = 0; + const { rerender } = render( + , + ); + /* Pre-load: correctly declines, because nothing yet says this lane is WIP. */ + expect(subscribedNow()).toBe(false); + + rerender( + , + ); + + expect(subscribedNow()).toBe(true); + }); + + /* + The paired negative: recomputing must not degrade into "every card subscribes". A card whose + resolved traits say terminal is finished work and must stay off the shared ticker, or the fix + trades one stalled indicator for sixty cards waking a backgrounded tab — the exact cost the + shared-ticker refactor documented at this site. + */ + it("renamed vocabulary: a card in the renamed COMPLETE lane does not subscribe", () => { + tickerCalls.length = 0; + const { rerender } = render( + , + ); + rerender( + , + ); + + expect(subscribedNow()).toBe(false); + }); +});