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:
7
.changeset/fn-8826-dashboard-wip-badge.md
Normal file
7
.changeset/fn-8826-dashboard-wip-badge.md
Normal 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.
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user