diff --git a/.changeset/fn-8826-dashboard-wip-badge.md b/.changeset/fn-8826-dashboard-wip-badge.md
new file mode 100644
index 0000000000..5ffa3e38b7
--- /dev/null
+++ b/.changeset/fn-8826-dashboard-wip-badge.md
@@ -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.
diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx
index eb44a5c812..c9af0c0d09 100644
--- a/packages/dashboard/app/components/ListView.tsx
+++ b/packages/dashboard/app/components/ListView.tsx
@@ -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 (
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index 62987dc404..69dd0aaf37 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -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
diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx
index ed52865d72..6e647ec37e 100644
--- a/packages/dashboard/app/components/__tests__/ListView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx
@@ -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.
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index 658c677c31..de9cbcf034 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -400,7 +400,11 @@ describe("TaskCard", () => {
render();
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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+ expect(screen.getByText("Building")).toHaveClass("card-status-badge");
+ expect(container.querySelectorAll(".card-status-badge")).toHaveLength(1);
+
+ rerender();
+ expect(screen.getByText("executing")).toBeInTheDocument();
+ expect(container.querySelector(".card-status-badge")).not.toHaveTextContent(/in progress/i);
+
+ rerender();
+ 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.
diff --git a/packages/dashboard/app/utils/taskStatusBadgeLabel.ts b/packages/dashboard/app/utils/taskStatusBadgeLabel.ts
index 10c237ceaa..48e98c965b 100644
--- a/packages/dashboard/app/utils/taskStatusBadgeLabel.ts
+++ b/packages/dashboard/app/utils/taskStatusBadgeLabel.ts
@@ -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