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