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,
|
isNonPlanningOptionalGateBadge,
|
||||||
} from "../utils/taskProgress";
|
} from "../utils/taskProgress";
|
||||||
import { isTaskAgentActive } from "../utils/taskActivity";
|
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 { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||||
@@ -948,6 +948,11 @@ export function ListView({
|
|||||||
return fromOwnWorkflow ?? (own ? undefined : columnFlagsById.get(task.column));
|
return fromOwnWorkflow ?? (own ? undefined : columnFlagsById.get(task.column));
|
||||||
}, [columnFlagsById, taskContextMenuColumnsByTaskId]);
|
}, [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 getTaskPlanningWorkflowId = useCallback((task: Task): string | null => {
|
||||||
const taskWorkflowId = (task as Task & { workflowId?: string | null }).workflowId;
|
const taskWorkflowId = (task as Task & { workflowId?: string | null }).workflowId;
|
||||||
if (taskWorkflowId) return taskWorkflowId;
|
if (taskWorkflowId) return taskWorkflowId;
|
||||||
@@ -3163,8 +3168,18 @@ export function ListView({
|
|||||||
const suppressPlanningStatusBadge = showOptionalGateBadge && isNonPlanningOptionalGateBadge(optionalGateBadge);
|
const suppressPlanningStatusBadge = showOptionalGateBadge && isNonPlanningOptionalGateBadge(optionalGateBadge);
|
||||||
const isPlanningStatusBadge = !isReviewBudgetExhausted
|
const isPlanningStatusBadge = !isReviewBudgetExhausted
|
||||||
&& (isLivePlanning || isTransientPlannerActive || visualStatus === "planning");
|
&& (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")
|
const hasStatus = ((hasTaskStatusBadge(visualStatus) && visualStatus !== "queued")
|
||||||
|| isTransientPlannerActive)
|
|| isTransientPlannerActive
|
||||||
|
|| Boolean(wipLifecycleBadgeLabel))
|
||||||
&& !(suppressPlanningStatusBadge && isPlanningStatusBadge);
|
&& !(suppressPlanningStatusBadge && isPlanningStatusBadge);
|
||||||
/*
|
/*
|
||||||
FNXC:TaskStatusBadge 2026-07-26-14:05:
|
FNXC:TaskStatusBadge 2026-07-26-14:05:
|
||||||
@@ -3176,7 +3191,8 @@ export function ListView({
|
|||||||
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
|
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
|
||||||
: isLivePlanning || isTransientPlannerActive
|
: isLivePlanning || isTransientPlannerActive
|
||||||
? t("tasks.statusPlanning", "Planning")
|
? 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 hasDependencies = Boolean(task.dependencies && task.dependencies.length > 0);
|
||||||
const taskProgress = getTaskProgress(task, getTaskColumnFlags(task));
|
const taskProgress = getTaskProgress(task, getTaskColumnFlags(task));
|
||||||
const hasProgress = taskProgress.hasProgress;
|
const hasProgress = taskProgress.hasProgress;
|
||||||
@@ -3439,8 +3455,18 @@ export function ListView({
|
|||||||
const suppressPlanningStatusBadge = showOptionalGateBadge && isNonPlanningOptionalGateBadge(optionalGateBadge);
|
const suppressPlanningStatusBadge = showOptionalGateBadge && isNonPlanningOptionalGateBadge(optionalGateBadge);
|
||||||
const isPlanningStatusBadge = !isReviewBudgetExhausted
|
const isPlanningStatusBadge = !isReviewBudgetExhausted
|
||||||
&& (isLivePlanning || isTransientPlannerActive || visualStatus === "planning");
|
&& (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")
|
const showStatusBadge = ((hasTaskStatusBadge(visualStatus) && visualStatus !== "queued")
|
||||||
|| isTransientPlannerActive)
|
|| isTransientPlannerActive
|
||||||
|
|| Boolean(wipLifecycleBadgeLabel))
|
||||||
&& !(suppressPlanningStatusBadge && isPlanningStatusBadge);
|
&& !(suppressPlanningStatusBadge && isPlanningStatusBadge);
|
||||||
// FNXC:TaskStatusBadge 2026-07-26-14:05: the step-name override yields to the
|
// FNXC:TaskStatusBadge 2026-07-26-14:05: the step-name override yields to the
|
||||||
// gate badge — see the grouped-card render path above.
|
// gate badge — see the grouped-card render path above.
|
||||||
@@ -3448,7 +3474,8 @@ export function ListView({
|
|||||||
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
|
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
|
||||||
: isLivePlanning || isTransientPlannerActive
|
: isLivePlanning || isTransientPlannerActive
|
||||||
? t("tasks.statusPlanning", "Planning")
|
? 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;
|
const isDragging = draggingTaskId === task.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import {
|
|||||||
import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity";
|
import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity";
|
||||||
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
|
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
|
||||||
import { getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
|
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 { isReviewBudgetExhaustedApproval, isTaskAwaitingPlanApproval } from "../utils/reviewBudgetApproval";
|
||||||
import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback";
|
import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
@@ -3407,8 +3407,20 @@ function TaskCardComponent({
|
|||||||
const showQueuedBadge = !isPaused
|
const showQueuedBadge = !isPaused
|
||||||
&& !isWipColumn
|
&& !isWipColumn
|
||||||
&& (queued || visualStatus === "queued");
|
&& (queued || visualStatus === "queued");
|
||||||
|
const wipLifecycleBadgeLabel = !isPaused
|
||||||
|
&& !isStuck
|
||||||
|
&& !isPlanReviewReplanCapApproval
|
||||||
|
&& !isAwaitingApproval
|
||||||
|
&& !showOptionalGateBadge
|
||||||
|
&& !showReadyBadge
|
||||||
|
&& !showQueuedToPlanBadge
|
||||||
|
? getTaskWipLifecycleBadgeLabel(visualStatus, t, {
|
||||||
|
isWipColumn,
|
||||||
|
lifecycleLabel: taskActionColumnLabel(task.column),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
const showStatusBadge = !isPaused
|
const showStatusBadge = !isPaused
|
||||||
&& (hasTaskStatusBadge(visualStatus) || isTransientPlannerActive)
|
&& (hasTaskStatusBadge(visualStatus) || isTransientPlannerActive || Boolean(wipLifecycleBadgeLabel))
|
||||||
&& visualStatus !== "queued"
|
&& visualStatus !== "queued"
|
||||||
&& !(suppressPlanningStatusBadge && isPlanningStatusBadge);
|
&& !(suppressPlanningStatusBadge && isPlanningStatusBadge);
|
||||||
/*
|
/*
|
||||||
@@ -3440,7 +3452,8 @@ function TaskCardComponent({
|
|||||||
? t("tasks.queuedToPlan", "Queued to plan")
|
? t("tasks.queuedToPlan", "Queued to plan")
|
||||||
: showQueuedBadge
|
: showQueuedBadge
|
||||||
? t("tasks.statusQueued", "Queued")
|
? 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
|
const hasCardMetaBadges = showPriorityBadge
|
||||||
|| task.executionMode === "fast"
|
|| task.executionMode === "fast"
|
||||||
// FNXC:PlannerOversight 2026-07-04-00:00: the oversight badge is opt-in
|
// 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", () => {
|
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
|
// 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.
|
// 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} />);
|
render(<TaskCard task={staleSnapshotTask} onOpenDetail={noop} addToast={noop} />);
|
||||||
|
|
||||||
expect(screen.queryByTestId("planner-overseer-state-badge")).not.toBeInTheDocument();
|
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", () => {
|
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();
|
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", () => {
|
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
|
// 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.
|
// 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;
|
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:
|
FNXC:TaskStatusConsistency 2026-08-05-03:49:
|
||||||
A planner agent-log event can reach the shared board snapshot before the status update that changes
|
A planner agent-log event can reach the shared board snapshot before the status update that changes
|
||||||
|
|||||||
Reference in New Issue
Block a user