FN-8826: restore WIP lifecycle badges

Restore visible lifecycle badges for empty-status tasks in WIP lanes.

- Derive fallback badge labels from resolved workflow WIP traits and lane names
- Render the fallback consistently on board cards and desktop/mobile list rows
- Cover empty, custom-lane, populated, and paused status precedence

Files changed:
 .changeset/fn-8826-dashboard-wip-badge.md          |  7 +++
 packages/dashboard/app/components/ListView.tsx     | 37 +++++++++++--
 packages/dashboard/app/components/TaskCard.tsx     | 19 +++++--
 .../app/components/__tests__/ListView.test.tsx     | 61 ++++++++++++++++++++++
 .../app/components/__tests__/TaskCard.test.tsx     | 38 +++++++++++++-
 .../dashboard/app/utils/taskStatusBadgeLabel.ts    | 24 +++++++++
 6 files changed, 177 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-8826

Fusion-Task-Lineage: fd9c857a-a25f-44d0-ae66-502bc1bd860e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-07 20:33:42 -07:00
parent 3dd824d04e
commit 1f5c44ac71
6 changed files with 177 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Restore the In progress badge for active dashboard tasks with no transient status.
category: fix
dev: Uses resolved workflow WIP traits and lifecycle labels across board and list views.

View File

@@ -29,7 +29,7 @@ import {
isNonPlanningOptionalGateBadge,
} from "../utils/taskProgress";
import { isTaskAgentActive } from "../utils/taskActivity";
import { getTaskStatusBadgeLabel, hasTaskStatusBadge, isTaskPlanningActive, type TaskStatusBadgeContext } from "../utils/taskStatusBadgeLabel";
import { getTaskStatusBadgeLabel, getTaskWipLifecycleBadgeLabel, hasTaskStatusBadge, isTaskPlanningActive, type TaskStatusBadgeContext } from "../utils/taskStatusBadgeLabel";
import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval";
import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
@@ -948,6 +948,11 @@ export function ListView({
return fromOwnWorkflow ?? (own ? undefined : columnFlagsById.get(task.column));
}, [columnFlagsById, taskContextMenuColumnsByTaskId]);
const getTaskColumnDisplayLabel = useCallback((task: Task): string => {
return taskContextMenuColumnsByTaskId.get(task.id)?.find((column) => column.id === task.column)?.label
?? getListColumnLabel(task.column);
}, [getListColumnLabel, taskContextMenuColumnsByTaskId]);
const getTaskPlanningWorkflowId = useCallback((task: Task): string | null => {
const taskWorkflowId = (task as Task & { workflowId?: string | null }).workflowId;
if (taskWorkflowId) return taskWorkflowId;
@@ -3163,8 +3168,18 @@ export function ListView({
const suppressPlanningStatusBadge = showOptionalGateBadge && isNonPlanningOptionalGateBadge(optionalGateBadge);
const isPlanningStatusBadge = !isReviewBudgetExhausted
&& (isLivePlanning || isTransientPlannerActive || visualStatus === "planning");
const wipLifecycleBadgeLabel = !isPaused
&& !isStuckState
&& !isReviewBudgetExhausted
&& !showOptionalGateBadge
? getTaskWipLifecycleBadgeLabel(visualStatus, t, {
isWipColumn: isWipColumnRole(getTaskColumnFlags(task), task.column),
lifecycleLabel: getTaskColumnDisplayLabel(task),
})
: null;
const hasStatus = ((hasTaskStatusBadge(visualStatus) && visualStatus !== "queued")
|| isTransientPlannerActive)
|| isTransientPlannerActive
|| Boolean(wipLifecycleBadgeLabel))
&& !(suppressPlanningStatusBadge && isPlanningStatusBadge);
/*
FNXC:TaskStatusBadge 2026-07-26-14:05:
@@ -3176,7 +3191,8 @@ export function ListView({
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
: isLivePlanning || isTransientPlannerActive
? t("tasks.statusPlanning", "Planning")
: getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null });
: wipLifecycleBadgeLabel
?? getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null });
const hasDependencies = Boolean(task.dependencies && task.dependencies.length > 0);
const taskProgress = getTaskProgress(task, getTaskColumnFlags(task));
const hasProgress = taskProgress.hasProgress;
@@ -3439,8 +3455,18 @@ export function ListView({
const suppressPlanningStatusBadge = showOptionalGateBadge && isNonPlanningOptionalGateBadge(optionalGateBadge);
const isPlanningStatusBadge = !isReviewBudgetExhausted
&& (isLivePlanning || isTransientPlannerActive || visualStatus === "planning");
const wipLifecycleBadgeLabel = !isPaused
&& !isStuckState
&& !isReviewBudgetExhausted
&& !showOptionalGateBadge
? getTaskWipLifecycleBadgeLabel(visualStatus, t, {
isWipColumn: isWipColumnRole(getTaskColumnFlags(task), task.column),
lifecycleLabel: getTaskColumnDisplayLabel(task),
})
: null;
const showStatusBadge = ((hasTaskStatusBadge(visualStatus) && visualStatus !== "queued")
|| isTransientPlannerActive)
|| isTransientPlannerActive
|| Boolean(wipLifecycleBadgeLabel))
&& !(suppressPlanningStatusBadge && isPlanningStatusBadge);
// FNXC:TaskStatusBadge 2026-07-26-14:05: the step-name override yields to the
// gate badge — see the grouped-card render path above.
@@ -3448,7 +3474,8 @@ export function ListView({
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
: isLivePlanning || isTransientPlannerActive
? t("tasks.statusPlanning", "Planning")
: getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null });
: wipLifecycleBadgeLabel
?? getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null });
const isDragging = draggingTaskId === task.id;
return (

View File

@@ -57,7 +57,7 @@ import {
import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity";
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
import { getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
import { getTaskStatusBadgeLabel, type TaskStatusBadgeContext, hasTaskStatusBadge, isTaskPlanningActive } from "../utils/taskStatusBadgeLabel";
import { getTaskStatusBadgeLabel, getTaskWipLifecycleBadgeLabel, type TaskStatusBadgeContext, hasTaskStatusBadge, isTaskPlanningActive } from "../utils/taskStatusBadgeLabel";
import { isReviewBudgetExhaustedApproval, isTaskAwaitingPlanApproval } from "../utils/reviewBudgetApproval";
import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback";
import type { ToastType } from "../hooks/useToast";
@@ -3407,8 +3407,20 @@ function TaskCardComponent({
const showQueuedBadge = !isPaused
&& !isWipColumn
&& (queued || visualStatus === "queued");
const wipLifecycleBadgeLabel = !isPaused
&& !isStuck
&& !isPlanReviewReplanCapApproval
&& !isAwaitingApproval
&& !showOptionalGateBadge
&& !showReadyBadge
&& !showQueuedToPlanBadge
? getTaskWipLifecycleBadgeLabel(visualStatus, t, {
isWipColumn,
lifecycleLabel: taskActionColumnLabel(task.column),
})
: null;
const showStatusBadge = !isPaused
&& (hasTaskStatusBadge(visualStatus) || isTransientPlannerActive)
&& (hasTaskStatusBadge(visualStatus) || isTransientPlannerActive || Boolean(wipLifecycleBadgeLabel))
&& visualStatus !== "queued"
&& !(suppressPlanningStatusBadge && isPlanningStatusBadge);
/*
@@ -3440,7 +3452,8 @@ function TaskCardComponent({
? t("tasks.queuedToPlan", "Queued to plan")
: showQueuedBadge
? t("tasks.statusQueued", "Queued")
: getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null });
: wipLifecycleBadgeLabel
?? getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null });
const hasCardMetaBadges = showPriorityBadge
|| task.executionMode === "fast"
// FNXC:PlannerOversight 2026-07-04-00:00: the oversight badge is opt-in

View File

@@ -2845,6 +2845,67 @@ describe("ListView", () => {
}
});
it.each([null, undefined])("renders exactly one WIP lifecycle badge for empty status on desktop and grouped list paths (%s)", (status) => {
const task = createMockTask({ id: `FN-8826-${status ?? "null"}`, column: "in-progress", status: status as any });
const desktopViewport = mockDesktopViewport();
try {
const { unmount } = renderListView({ tasks: [task] });
const row = screen.getByText(task.id).closest("tr") as HTMLElement;
expect(row.querySelector(".list-status-badge")).toHaveTextContent(/in progress/i);
expect(row.querySelectorAll(".list-status-badge")).toHaveLength(1);
unmount();
} finally {
desktopViewport.mockRestore();
}
const mobileViewport = mockMobileViewport();
try {
renderListView({ tasks: [task] });
const card = screen.getByText(task.id).closest(".list-card") as HTMLElement;
expect(card.querySelector(".list-status-badge")).toHaveTextContent(/in progress/i);
expect(card.querySelectorAll(".list-status-badge")).toHaveLength(1);
} finally {
mobileViewport.mockRestore();
}
});
it("uses task-specific custom WIP traits and keeps populated status authoritative", () => {
const workflowPayload = {
...DEFAULT_LANE_PAYLOAD,
defaultWorkflowId: "wf-custom",
workflows: [{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "ideas", name: "Ideas", flags: { intake: true } },
{ id: "building", name: "Building", flags: { countsTowardWip: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
}],
taskWorkflowIds: { "FN-8826-custom": "wf-custom" },
};
vi.mocked(fetchBoardWorkflows).mockResolvedValue(workflowPayload);
writeBoardWorkflowsCache(TEST_PROJECT_ID, workflowPayload);
const desktopViewport = mockDesktopViewport();
try {
const first = renderListView({
tasks: [createMockTask({ id: "FN-8826-custom", column: "building" as any, status: undefined as any })],
});
const row = screen.getByText("FN-8826-custom").closest("tr") as HTMLElement;
expect(row.querySelector(".list-status-badge")).toHaveTextContent("Building");
expect(row.querySelectorAll(".list-status-badge")).toHaveLength(1);
first.unmount();
renderListView({ tasks: [createMockTask({ id: "FN-8826-custom", column: "building" as any, status: "executing" })] });
const executingRow = screen.getByText("FN-8826-custom").closest("tr") as HTMLElement;
expect(executingRow.querySelector(".list-status-badge")).toHaveTextContent("executing");
} finally {
desktopViewport.mockRestore();
}
});
it("FN-8493 renders the idle Queued to revise label, not Replan, for bare needs-replan list rows on desktop and mobile", () => {
// FNXC:TaskActivity 2026-08-01-17:53: a parked replan is idle (no concurrency slot), so
// list rows show the descriptive waiting label rather than the live "Revising" copy.

View File

@@ -400,7 +400,11 @@ describe("TaskCard", () => {
render(<TaskCard task={staleSnapshotTask} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByTestId("planner-overseer-state-badge")).not.toBeInTheDocument();
expect(screen.queryByTestId("card-header-badges")).not.toBeInTheDocument();
if (column === "in-progress") {
expect(screen.getByTestId("card-header-badges")).toHaveTextContent(/in progress/i);
} else {
expect(screen.queryByTestId("card-header-badges")).not.toBeInTheDocument();
}
});
it("does not render an overseer badge for a stale non-idle oversight-off snapshot", () => {
@@ -2472,6 +2476,38 @@ describe("TaskCard", () => {
expect(screen.getByText("executing")).toBeDefined();
});
it.each([null, undefined, " "])("restores one WIP lifecycle badge for an empty status (%s)", (status) => {
const { container } = render(
<TaskCard task={makeTask({ status: status as any })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card-status-badge")).toHaveTextContent(/in progress/i);
expect(container.querySelector(".card-status-badge")).toHaveClass("card-status-badge--in-progress");
expect(container.querySelectorAll(".card-status-badge")).toHaveLength(1);
});
it("uses a renamed WIP lane label without replacing richer or paused states", () => {
const { container, rerender } = render(
<TaskCard
task={makeTask({ column: "building" as any, status: undefined as any })}
taskColumnFlags={{ countsTowardWip: true }}
taskMoveColumns={[{ id: "building" as any, label: "Building", flags: { countsTowardWip: true } }]}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Building")).toHaveClass("card-status-badge");
expect(container.querySelectorAll(".card-status-badge")).toHaveLength(1);
rerender(<TaskCard task={makeTask({ status: "executing" })} onOpenDetail={noop} addToast={noop} />);
expect(screen.getByText("executing")).toBeInTheDocument();
expect(container.querySelector(".card-status-badge")).not.toHaveTextContent(/in progress/i);
rerender(<TaskCard task={makeTask({ status: undefined as any, paused: true })} onOpenDetail={noop} addToast={noop} />);
expect(screen.getByText("paused")).toBeInTheDocument();
expect(container.querySelector(".card-status-badge")).not.toHaveTextContent(/in progress/i);
});
it("FN-8493 renders the idle Queued to revise label, not Replan, for a bare needs-replan Board card", () => {
// FNXC:TaskActivity 2026-08-01-17:53: needs-replan holds no concurrency slot, so the card is
// idle — it renders the descriptive waiting label instead of the live "Revising" copy.

View File

@@ -16,6 +16,30 @@ export function hasTaskStatusBadge(status: string | null | undefined): boolean {
return typeof status === "string" && status.trim().length > 0;
}
export interface TaskWipLifecycleBadgeContext {
/** True when the task's resolved workflow column occupies an implementation/WIP slot. */
isWipColumn: boolean;
/** The resolved workflow column name, when board metadata has loaded. */
lifecycleLabel?: string | null;
}
/*
FNXC:TaskStatusBadge 2026-08-08-03:02:
A WIP lifecycle role supplies a badge only when the persisted status is empty: engine status,
pause, workflow-gate, and other call-site precedence remains authoritative. Resolve WIP from
workflow traits before calling this helper so renamed/custom implementation lanes receive their
own lifecycle label; metadata gaps retain the safe legacy "In progress" fallback without writing
that presentation state back to task.status.
*/
export function getTaskWipLifecycleBadgeLabel(
status: string | null | undefined,
t: TFunction<"app">,
{ isWipColumn, lifecycleLabel }: TaskWipLifecycleBadgeContext,
): string | null {
if (!isWipColumn || hasTaskStatusBadge(status)) return null;
return lifecycleLabel?.trim() || t("tasks.statusInProgress", "In progress");
}
/*
FNXC:TaskStatusConsistency 2026-08-05-03:49:
A planner agent-log event can reach the shared board snapshot before the status update that changes