fix(dashboard): badge lane-owned gates and keep WIP progress clean
Suppress Ready while Plan Review is active, show only implementation steps on In-progress progress, and surface Plan Review / Code Review / Browser Verification as header badges on their lanes instead of progress bullets.
This commit is contained in:
@@ -22,7 +22,7 @@ import type { ToastType } from "../hooks/useToast";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
|
||||
import { getRunningWorkflowStepLabel, getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
|
||||
import { getRunningOptionalGateBadge, getRunningWorkflowStepLabel, getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { isTaskAgentActive } from "../utils/taskActivity";
|
||||
import { getTaskStatusBadgeLabel, shouldSuppressPlanningStatusBadge } from "../utils/taskStatusBadgeLabel";
|
||||
import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval";
|
||||
@@ -304,7 +304,11 @@ function shouldShowTaskProgress(task: Task): boolean {
|
||||
}
|
||||
|
||||
function getTaskProgress(task: Task): { label: string; percent: number; hasProgress: boolean } {
|
||||
const progress = getUnifiedTaskProgress(task);
|
||||
/*
|
||||
FNXC:TaskCardWorkflowProgress 2026-07-21-22:26:
|
||||
List progress for WIP matches TaskCard: only implementation steps, not Todo Plan Review or In-review Code Review gates.
|
||||
*/
|
||||
const progress = getUnifiedTaskProgress(task, { scope: "implementation" });
|
||||
if (progress.total === 0 || !shouldShowTaskProgress(task)) {
|
||||
return { label: "-", percent: 0, hasProgress: false };
|
||||
}
|
||||
@@ -2678,7 +2682,8 @@ export function ListView({
|
||||
|| isTransientPlannerActive)
|
||||
&& !shouldSuppressPlanningStatusBadge({ status: visualStatus, column: task.column });
|
||||
const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task);
|
||||
const planReviewRunning = isPlanReviewRunning(task);
|
||||
const optionalGateBadge = getRunningOptionalGateBadge(task);
|
||||
const showOptionalGateBadge = Boolean(optionalGateBadge) && isAgentActive;
|
||||
const hasDependencies = Boolean(task.dependencies && task.dependencies.length > 0);
|
||||
const taskProgress = getTaskProgress(task);
|
||||
const hasProgress = taskProgress.hasProgress;
|
||||
@@ -2746,13 +2751,27 @@ export function ListView({
|
||||
: getTaskStatusLabel(visualStatus ?? "", t, getRunningWorkflowStepLabel(task))}
|
||||
</span>
|
||||
) : null}
|
||||
{planReviewRunning && isAgentActive && (
|
||||
{showOptionalGateBadge && optionalGateBadge && (
|
||||
/*
|
||||
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:10:
|
||||
Grouped ListView cards must show the same active Plan Review "Reviewing" badge as TaskCard so board and list surfaces remain visually equivalent while the `plan-review` workflow step is running.
|
||||
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Same badge contract for Code Review / Browser Verification in In-review.
|
||||
*/
|
||||
<span className="list-status-badge list-status-badge--reviewing pulsing">
|
||||
{t("listView.reviewing", "Reviewing")}
|
||||
<span
|
||||
className="list-status-badge list-status-badge--reviewing pulsing"
|
||||
data-testid={`list-${optionalGateBadge.testId}-${task.id}`}
|
||||
data-optional-gate={optionalGateBadge.workflowStepId}
|
||||
title={
|
||||
optionalGateBadge.workflowStepId === "plan-review" || optionalGateBadge.workflowStepId === "plan-replan"
|
||||
? t("tasks.planReviewingTitle", "Plan Review in progress")
|
||||
: t("tasks.optionalGateRunningTitle", "{{name}} in progress", { name: optionalGateBadge.name })
|
||||
}
|
||||
>
|
||||
{optionalGateBadge.workflowStepId === "plan-review" || optionalGateBadge.workflowStepId === "plan-replan"
|
||||
? t("listView.reviewing", "Reviewing")
|
||||
: optionalGateBadge.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -2899,7 +2918,8 @@ export function ListView({
|
||||
&& isAgentActive;
|
||||
const showStatusBadge = (Boolean(visualStatus) || isTransientPlannerActive)
|
||||
&& !shouldSuppressPlanningStatusBadge({ status: visualStatus, column: task.column });
|
||||
const planReviewRunning = isPlanReviewRunning(task);
|
||||
const optionalGateBadge = getRunningOptionalGateBadge(task);
|
||||
const showOptionalGateBadge = Boolean(optionalGateBadge) && isAgentActive;
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
|
||||
return (
|
||||
@@ -2981,13 +3001,27 @@ export function ListView({
|
||||
) : (
|
||||
<span className="list-status-badge">-</span>
|
||||
)}
|
||||
{planReviewRunning && isAgentActive && (
|
||||
{showOptionalGateBadge && optionalGateBadge && (
|
||||
/*
|
||||
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:11:
|
||||
Ungrouped ListView table rows must render the same Reviewing badge from the shared predicate; this second status render path is easy to miss and must stay in parity with grouped rows.
|
||||
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Same badge contract for Code Review / Browser Verification in In-review.
|
||||
*/
|
||||
<span className="list-status-badge list-status-badge--reviewing pulsing">
|
||||
{t("listView.reviewing", "Reviewing")}
|
||||
<span
|
||||
className="list-status-badge list-status-badge--reviewing pulsing"
|
||||
data-testid={`list-${optionalGateBadge.testId}-${task.id}`}
|
||||
data-optional-gate={optionalGateBadge.workflowStepId}
|
||||
title={
|
||||
optionalGateBadge.workflowStepId === "plan-review" || optionalGateBadge.workflowStepId === "plan-replan"
|
||||
? t("tasks.planReviewingTitle", "Plan Review in progress")
|
||||
: t("tasks.optionalGateRunningTitle", "{{name}} in progress", { name: optionalGateBadge.name })
|
||||
}
|
||||
>
|
||||
{optionalGateBadge.workflowStepId === "plan-review" || optionalGateBadge.workflowStepId === "plan-replan"
|
||||
? t("listView.reviewing", "Reviewing")
|
||||
: optionalGateBadge.label}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -331,6 +331,9 @@ FN-7780 anchors the created-by-agent badge in its own bottom-left row before wor
|
||||
/*
|
||||
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:15:
|
||||
The Plan Review "Reviewing" badge must read as an active review state without adding custom sizing. Reuse the in-review semantic token and existing status badge geometry so desktop and mobile header wrapping keep the same invariants.
|
||||
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Code Review / Browser Verification reuse the same reviewing token while the card is in In-review so lane-owned gates share one active-review chrome.
|
||||
*/
|
||||
.card-status-badge--reviewing {
|
||||
background: color-mix(in srgb, var(--in-review) 18%, transparent);
|
||||
|
||||
@@ -39,7 +39,7 @@ import { getStalledReviewSignal } from "../utils/taskStalledReview";
|
||||
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
|
||||
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
|
||||
import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy";
|
||||
import { getRunningWorkflowStepLabel, getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
|
||||
import { getRunningOptionalGateBadge, getRunningWorkflowStepLabel, getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress";
|
||||
import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity";
|
||||
import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
|
||||
import { getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
|
||||
@@ -1326,11 +1326,18 @@ function TaskCardComponent({
|
||||
/*
|
||||
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:05:
|
||||
FN-7831 requires the card header to show a distinct "Reviewing" badge while the optional `plan-review` workflow step is actively running, even while the card remains in Planning/`triage`. Use the shared predicate so TaskCard stays in sync with ListView.
|
||||
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Extend the same additive header-badge pattern to Code Review / Browser Verification while the card is in In-review. Lane-owned optional gates never appear in the WIP bullet list; they surface only as this badge while running.
|
||||
*/
|
||||
const planReviewRunning = useMemo(
|
||||
() => isPlanReviewRunning(task),
|
||||
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults],
|
||||
);
|
||||
const optionalGateBadge = useMemo(
|
||||
() => getRunningOptionalGateBadge(task),
|
||||
[task.column, task.steps, task.enabledWorkflowSteps, task.workflowStepResults],
|
||||
);
|
||||
// CLI agent session badges (U11) — distinct from staleness/stall badges.
|
||||
const cliWaitingOnInput = cliSessionState?.agentState === "waitingOnInput";
|
||||
const cliNeedsAttention = cliSessionState?.agentState === "needsAttention";
|
||||
@@ -1356,6 +1363,21 @@ function TaskCardComponent({
|
||||
const isAwaitingInput = task.status === "awaiting-user-input";
|
||||
const isArchived = task.column === "archived";
|
||||
const isAgentActive = isTaskAgentActive(task, { globalPaused, queued, isStuck });
|
||||
/*
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Match FN-8055: optional-gate badges pulse only while the card is agent-active (queue/pause/stuck gates suppress the badge).
|
||||
*/
|
||||
const showOptionalGateBadge = Boolean(optionalGateBadge) && isAgentActive;
|
||||
/*
|
||||
FNXC:CodingIdeasWorkflow 2026-07-21-22:18:
|
||||
Ready is the idle capacity-hold signal for Coding (Ideas) Todo cards that already have steps and no task.status. Plan Review (and any other agent-active work) also runs in Todo with status often cleared to null first, so Ready must suppress while plan-review is running or the card is agent-active — otherwise operators see both Ready and Reviewing on the same card.
|
||||
*/
|
||||
const showReadyBadge = !isPaused
|
||||
&& task.column === "todo"
|
||||
&& !visualStatus
|
||||
&& (task.steps?.length ?? 0) > 0
|
||||
&& !planReviewRunning
|
||||
&& !isAgentActive;
|
||||
// Native HTML5 drag is desktop-mouse only — it doesn't move cards via touch.
|
||||
// On touch-primary devices the `draggable` attribute still arms the browser's
|
||||
// touch-drag heuristic, which intermittently hijacks horizontal swipes meant
|
||||
@@ -1455,8 +1477,12 @@ function TaskCardComponent({
|
||||
}
|
||||
return providers;
|
||||
}, [task.modelProvider, task.validatorModelProvider, task.planningModelProvider]);
|
||||
/*
|
||||
FNXC:TaskCardWorkflowProgress 2026-07-21-22:26:
|
||||
In-progress card progress is WIP implementation only. Plan Review (Todo) and Code Review / other review-lane gates must not appear as checklist rows or inflate completed/total while the card is in In progress; badges still use full progress helpers (isPlanReviewRunning / running step labels).
|
||||
*/
|
||||
const unifiedProgress = useMemo(
|
||||
() => getUnifiedTaskProgress(task),
|
||||
() => getUnifiedTaskProgress(task, { scope: "implementation" }),
|
||||
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults],
|
||||
);
|
||||
/*
|
||||
@@ -2958,8 +2984,8 @@ function TaskCardComponent({
|
||||
|| showOversightBadge;
|
||||
const hasHeaderBadges = Boolean(isPaused)
|
||||
|| showStatusBadge
|
||||
|| (planReviewRunning && isAgentActive)
|
||||
|| Boolean(!isPaused && task.column === "todo" && !visualStatus && (task.steps?.length ?? 0) > 0)
|
||||
|| showOptionalGateBadge
|
||||
|| showReadyBadge
|
||||
|| Boolean(hasInReviewStall && stallCopy)
|
||||
|| cliWaitingOnInput
|
||||
|| cliNeedsAttention
|
||||
@@ -3124,24 +3150,37 @@ function TaskCardComponent({
|
||||
: getTaskStatusLabel(visualStatus!, t, getRunningWorkflowStepLabel(task))}
|
||||
</span>
|
||||
)}
|
||||
{planReviewRunning && isAgentActive && (
|
||||
{showOptionalGateBadge && optionalGateBadge && (
|
||||
/*
|
||||
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:06:
|
||||
The Reviewing badge is additive to the normal header status badge so operators can distinguish "planning" from active Plan Review without hiding paused/stuck/status affordances.
|
||||
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Same additive pattern for Code Review / Browser Verification in In-review. Label is the gate's own name (Plan Review keeps the short "Reviewing" copy). These gates stay out of the WIP bullet list.
|
||||
*/
|
||||
<span
|
||||
className="card-status-badge card-status-badge--reviewing pulsing"
|
||||
data-testid={`card-reviewing-${task.id}`}
|
||||
title={t("tasks.planReviewingTitle", "Plan Review in progress")}
|
||||
data-testid={`card-${optionalGateBadge.testId}-${task.id}`}
|
||||
data-optional-gate={optionalGateBadge.workflowStepId}
|
||||
title={
|
||||
optionalGateBadge.workflowStepId === "plan-review" || optionalGateBadge.workflowStepId === "plan-replan"
|
||||
? t("tasks.planReviewingTitle", "Plan Review in progress")
|
||||
: t("tasks.optionalGateRunningTitle", "{{name}} in progress", { name: optionalGateBadge.name })
|
||||
}
|
||||
>
|
||||
{t("tasks.reviewing", "Reviewing")}
|
||||
{optionalGateBadge.workflowStepId === "plan-review" || optionalGateBadge.workflowStepId === "plan-replan"
|
||||
? t("tasks.reviewing", "Reviewing")
|
||||
: optionalGateBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{/*
|
||||
FNXC:CodingIdeasWorkflow 2026-07-04-11:10:
|
||||
In the merged planner/capacity "todo" column (Coding (Ideas)), a planned task with no active status is ready and waiting for an in-progress slot. Show a "Ready" badge so operators can distinguish planned cards from freshly promoted unplanned ones. Tasks still being planned surface the "planning" status badge above instead.
|
||||
|
||||
FNXC:CodingIdeasWorkflow 2026-07-21-22:18:
|
||||
Suppress Ready while Plan Review (or other agent-active work) is live — finalize often clears status before plan-review, which previously stacked Ready + Reviewing on the same Todo card.
|
||||
*/}
|
||||
{!isPaused && task.column === "todo" && !visualStatus && (task.steps?.length ?? 0) > 0 && (
|
||||
{showReadyBadge && (
|
||||
<span className="card-status-badge card-status-badge--todo ready" data-testid={`card-ready-${task.id}`}>
|
||||
{t("tasks.ready", "Ready")}
|
||||
</span>
|
||||
|
||||
@@ -2441,6 +2441,159 @@ describe("TaskCard", () => {
|
||||
expect(container.querySelector('[data-testid="card-reviewing-FN-8055-queued"]')).toBeNull();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Code Review and Browser Verification surface as header badges on In-review cards while running — not as progress bullet rows.
|
||||
*/
|
||||
it.each([
|
||||
{
|
||||
id: "FN-CR",
|
||||
workflowStepId: "code-review",
|
||||
workflowStepName: "Code Review",
|
||||
testId: "card-code-review-FN-CR",
|
||||
label: "Code Review",
|
||||
},
|
||||
{
|
||||
id: "FN-BV",
|
||||
workflowStepId: "browser-verification",
|
||||
workflowStepName: "Browser Verification",
|
||||
testId: "card-browser-verification-FN-BV",
|
||||
label: "Browser Verification",
|
||||
},
|
||||
])("renders a $label badge while that optional gate is running in In-review", ({ id, workflowStepId, workflowStepName, testId, label }) => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id,
|
||||
column: "in-review",
|
||||
status: null as any,
|
||||
steps: [{ name: "Step 0", status: "done" }],
|
||||
enabledWorkflowSteps: [workflowStepId],
|
||||
workflowStepResults: [{
|
||||
workflowStepId,
|
||||
workflowStepName,
|
||||
status: "pending",
|
||||
startedAt: "2026-07-11T12:00:00.000Z",
|
||||
}],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(`[data-testid="${testId}"]`);
|
||||
expect(badge).toHaveTextContent(label);
|
||||
expect(badge?.className).toContain("pulsing");
|
||||
expect(container.querySelector(".card-progress")).toBeNull();
|
||||
expect(container.querySelector(".card-steps-list")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not badge Code Review while the card is still in-progress", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-CR-WIP",
|
||||
column: "in-progress",
|
||||
status: "executing" as any,
|
||||
steps: [{ name: "Step 0", status: "done" }],
|
||||
enabledWorkflowSteps: ["code-review"],
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "code-review",
|
||||
workflowStepName: "Code Review",
|
||||
status: "pending",
|
||||
startedAt: "2026-07-11T12:00:00.000Z",
|
||||
}],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="card-code-review-FN-CR-WIP"]')).toBeNull();
|
||||
expect(screen.getByText("1/1")).toBeDefined();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:CodingIdeasWorkflow 2026-07-21-22:18:
|
||||
Coding (Ideas) Todo shows Ready for idle planned cards (steps present, status null). Plan Review also runs in Todo after finalize clears status, so Ready must not stack with the Reviewing badge while plan-review is running.
|
||||
*/
|
||||
it("renders Ready on an idle planned Todo card", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-READY-IDLE",
|
||||
column: "todo",
|
||||
status: null as any,
|
||||
steps: [{ id: "s1", title: "Step 1", status: "pending" }] as Task["steps"],
|
||||
enabledWorkflowSteps: ["plan-review"],
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "plan-review",
|
||||
workflowStepName: "Plan Review",
|
||||
status: "passed",
|
||||
startedAt: "2026-07-11T12:00:00.000Z",
|
||||
completedAt: "2026-07-11T12:01:00.000Z",
|
||||
}],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="card-ready-FN-READY-IDLE"]')).toHaveTextContent("Ready");
|
||||
expect(container.querySelector('[data-testid="card-reviewing-FN-READY-IDLE"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render Ready while Plan Review is running on a status-null Todo card", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-READY-REVIEW",
|
||||
column: "todo",
|
||||
status: null as any,
|
||||
steps: [{ id: "s1", title: "Step 1", status: "pending" }] as Task["steps"],
|
||||
enabledWorkflowSteps: ["plan-review"],
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "plan-review",
|
||||
workflowStepName: "Plan Review",
|
||||
status: "pending",
|
||||
startedAt: "2026-07-11T12:00:00.000Z",
|
||||
}],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="card-ready-FN-READY-REVIEW"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="card-reviewing-FN-READY-REVIEW"]')).toHaveTextContent("Reviewing");
|
||||
});
|
||||
|
||||
it("does not render Ready while Plan Review is running even when the queue gate hides Reviewing", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
id: "FN-READY-QUEUED",
|
||||
column: "todo",
|
||||
status: null as any,
|
||||
steps: [{ id: "s1", title: "Step 1", status: "pending" }] as Task["steps"],
|
||||
enabledWorkflowSteps: ["plan-review"],
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "plan-review",
|
||||
workflowStepName: "Plan Review",
|
||||
status: "pending",
|
||||
startedAt: "2026-07-11T12:00:00.000Z",
|
||||
}],
|
||||
})}
|
||||
queued
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="card-reviewing-FN-READY-QUEUED"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="card-ready-FN-READY-QUEUED"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the status badge after the card ID in DOM order", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
@@ -3491,7 +3644,7 @@ describe("TaskCard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders unified progress counts for task steps + workflow checks", () => {
|
||||
it("renders unified progress counts for task steps + non-lane workflow checks", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
@@ -3522,6 +3675,48 @@ describe("TaskCard", () => {
|
||||
expect(screen.getByText("5 steps")).toBeDefined();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TaskCardWorkflowProgress 2026-07-21-22:26:
|
||||
In-progress progress is WIP-only. Plan Review (Todo) and Code Review (In-review) must not appear in the card checklist or completed/total counts.
|
||||
*/
|
||||
it("excludes Plan Review and Code Review from in-progress progress counts", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "in-progress",
|
||||
status: "executing" as any,
|
||||
steps: [
|
||||
{ name: "Step 0", status: "done" },
|
||||
{ name: "Step 1", status: "pending" },
|
||||
],
|
||||
enabledWorkflowSteps: ["plan-review", "code-review"],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "plan-review",
|
||||
workflowStepName: "Plan Review",
|
||||
status: "passed",
|
||||
startedAt: "2026-07-11T12:00:00.000Z",
|
||||
completedAt: "2026-07-11T12:01:00.000Z",
|
||||
},
|
||||
{
|
||||
workflowStepId: "code-review",
|
||||
workflowStepName: "Code Review",
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1/2")).toBeDefined();
|
||||
expect(screen.getByText("2 steps")).toBeDefined();
|
||||
expect(screen.queryByText("1/4")).toBeNull();
|
||||
expect(screen.queryByText("Plan Review")).toBeNull();
|
||||
expect(screen.queryByText("Code Review")).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces in-progress implementation steps on the collapsed card", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { getUnifiedTaskProgress, isPlanReviewRunning } from "../taskProgress";
|
||||
import { getRunningOptionalGateBadge, getUnifiedTaskProgress, isPlanReviewRunning } from "../taskProgress";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSteps 2026-06-25-00:00 — graph-native progress model (plan U3).
|
||||
@@ -34,6 +34,92 @@ describe("isPlanReviewRunning", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Lane-owned optional gates badge only in their planning/review columns while running.
|
||||
*/
|
||||
describe("getRunningOptionalGateBadge", () => {
|
||||
const runningPlanReview = {
|
||||
workflowStepId: "plan-review",
|
||||
workflowStepName: "Plan Review",
|
||||
status: "pending" as const,
|
||||
startedAt: "2026-07-11T12:00:00.000Z",
|
||||
};
|
||||
const runningCodeReview = {
|
||||
workflowStepId: "code-review",
|
||||
workflowStepName: "Code Review",
|
||||
status: "pending" as const,
|
||||
startedAt: "2026-07-11T12:00:00.000Z",
|
||||
};
|
||||
const runningBrowser = {
|
||||
workflowStepId: "browser-verification",
|
||||
workflowStepName: "Browser Verification",
|
||||
status: "pending" as const,
|
||||
startedAt: "2026-07-11T12:00:00.000Z",
|
||||
};
|
||||
|
||||
it("badges Plan Review on triage and todo only", () => {
|
||||
for (const column of ["triage", "todo"] as const) {
|
||||
const badge = getRunningOptionalGateBadge({
|
||||
...makeTask({
|
||||
enabledWorkflowSteps: ["plan-review"],
|
||||
workflowStepResults: [runningPlanReview],
|
||||
}),
|
||||
column,
|
||||
} as Task);
|
||||
expect(badge?.label).toBe("Reviewing");
|
||||
expect(badge?.testId).toBe("reviewing");
|
||||
}
|
||||
expect(getRunningOptionalGateBadge({
|
||||
...makeTask({
|
||||
enabledWorkflowSteps: ["plan-review"],
|
||||
workflowStepResults: [runningPlanReview],
|
||||
}),
|
||||
column: "in-progress",
|
||||
} as Task)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("badges Code Review and Browser Verification on in-review only", () => {
|
||||
expect(getRunningOptionalGateBadge({
|
||||
...makeTask({
|
||||
enabledWorkflowSteps: ["code-review"],
|
||||
workflowStepResults: [runningCodeReview],
|
||||
}),
|
||||
column: "in-review",
|
||||
} as Task)).toMatchObject({ label: "Code Review", testId: "code-review" });
|
||||
|
||||
expect(getRunningOptionalGateBadge({
|
||||
...makeTask({
|
||||
enabledWorkflowSteps: ["browser-verification"],
|
||||
workflowStepResults: [runningBrowser],
|
||||
}),
|
||||
column: "in-review",
|
||||
} as Task)).toMatchObject({ label: "Browser Verification", testId: "browser-verification" });
|
||||
|
||||
expect(getRunningOptionalGateBadge({
|
||||
...makeTask({
|
||||
enabledWorkflowSteps: ["code-review"],
|
||||
workflowStepResults: [runningCodeReview],
|
||||
}),
|
||||
column: "in-progress",
|
||||
} as Task)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the gate is not running", () => {
|
||||
expect(getRunningOptionalGateBadge({
|
||||
...makeTask({
|
||||
enabledWorkflowSteps: ["code-review"],
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "code-review",
|
||||
workflowStepName: "Code Review",
|
||||
status: "pending",
|
||||
}],
|
||||
}),
|
||||
column: "in-review",
|
||||
} as Task)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUnifiedTaskProgress", () => {
|
||||
it("resolves workflow step names from result.workflowStepName without a lookup", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
@@ -54,6 +140,50 @@ describe("getUnifiedTaskProgress", () => {
|
||||
expect(item?.status).toBe("done");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TaskCardWorkflowProgress 2026-07-21-22:26:
|
||||
Implementation scope is the WIP progress contract: Plan Review / Code Review and other lane-owned gates stay out of the in-progress checklist while full scope keeps the complete pipeline for detail and badges.
|
||||
*/
|
||||
it("implementation scope keeps only WIP steps, not Plan Review or Code Review", () => {
|
||||
const task = makeTask({
|
||||
steps: [
|
||||
{ name: "Implement feature", status: "done" },
|
||||
{ name: "Add tests", status: "in-progress" },
|
||||
] as Task["steps"],
|
||||
enabledWorkflowSteps: ["plan-review", "code-review", "browser-verification"],
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "plan-review",
|
||||
workflowStepName: "Plan Review",
|
||||
status: "passed",
|
||||
startedAt: "2026-07-11T12:00:00.000Z",
|
||||
completedAt: "2026-07-11T12:01:00.000Z",
|
||||
},
|
||||
{
|
||||
workflowStepId: "code-review",
|
||||
workflowStepName: "Code Review",
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const full = getUnifiedTaskProgress(task);
|
||||
expect(full.items.map((item) => item.id)).toEqual([
|
||||
"workflow-plan-review",
|
||||
"step-0",
|
||||
"step-1",
|
||||
"workflow-code-review",
|
||||
"workflow-browser-verification",
|
||||
]);
|
||||
expect(full.total).toBe(5);
|
||||
expect(full.completed).toBe(2);
|
||||
|
||||
const implementation = getUnifiedTaskProgress(task, { scope: "implementation" });
|
||||
expect(implementation.items.map((item) => item.id)).toEqual(["step-0", "step-1"]);
|
||||
expect(implementation.total).toBe(2);
|
||||
expect(implementation.completed).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to the workflow step id when no result name is available", () => {
|
||||
const progress = getUnifiedTaskProgress(
|
||||
makeTask({
|
||||
|
||||
@@ -87,9 +87,38 @@ function resolveWorkflowStepName(workflowStepId: string, result: WorkflowStepRes
|
||||
return humanizeWorkflowStepId(workflowStepId);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskCardWorkflowProgress 2026-07-21-22:26:
|
||||
In-progress progress is WIP implementation only. Built-in Plan Review lives in Todo/planning, Code Review / Browser Verification / post-merge / completion-summary / merge nodes live in In-review (or post-merge), so they must not inflate the in-progress checklist as pending/done segments. Board and list progress for WIP use `scope: "implementation"`; detail and badges keep the full pipeline via the default `scope: "full"`.
|
||||
*/
|
||||
const NON_IMPLEMENTATION_WORKFLOW_STEP_IDS = new Set([
|
||||
"plan-review",
|
||||
"plan-replan",
|
||||
"code-review",
|
||||
"browser-verification",
|
||||
"post-merge-verification",
|
||||
"completion-summary",
|
||||
]);
|
||||
|
||||
function isNonImplementationWorkflowStepId(workflowStepId: string): boolean {
|
||||
return NON_IMPLEMENTATION_WORKFLOW_STEP_IDS.has(workflowStepId) || workflowStepId.startsWith("merge-");
|
||||
}
|
||||
|
||||
export type UnifiedTaskProgressScope = "full" | "implementation";
|
||||
|
||||
export interface GetUnifiedTaskProgressOptions {
|
||||
/**
|
||||
* `full` — entire pipeline (implementation steps + optional gates + recorded nodes).
|
||||
* `implementation` — WIP-only: parsed task steps plus non-lane-gate workflow/node items.
|
||||
*/
|
||||
scope?: UnifiedTaskProgressScope;
|
||||
}
|
||||
|
||||
export function getUnifiedTaskProgress(
|
||||
task: Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">,
|
||||
options: GetUnifiedTaskProgressOptions = {},
|
||||
): UnifiedTaskProgress {
|
||||
const scope = options.scope ?? "full";
|
||||
const stepItems: UnifiedTaskProgressItem[] = (task.steps ?? []).map((step, index) => ({
|
||||
id: `step-${index}`,
|
||||
name: step.name,
|
||||
@@ -102,16 +131,18 @@ export function getUnifiedTaskProgress(
|
||||
(task.workflowStepResults ?? []).map((result) => [result.workflowStepId, result] as const),
|
||||
);
|
||||
|
||||
const workflowItems: UnifiedTaskProgressItem[] = (task.enabledWorkflowSteps ?? []).map((workflowStepId) => {
|
||||
const result = workflowResultsById.get(workflowStepId);
|
||||
return {
|
||||
id: `workflow-${workflowStepId}`,
|
||||
name: resolveWorkflowStepName(workflowStepId, result),
|
||||
status: result ? mapWorkflowStatus(result) : "pending",
|
||||
source: "workflow",
|
||||
phase: result?.phase ?? "pre-merge",
|
||||
};
|
||||
});
|
||||
const workflowItems: UnifiedTaskProgressItem[] = (task.enabledWorkflowSteps ?? [])
|
||||
.filter((workflowStepId) => scope === "full" || !isNonImplementationWorkflowStepId(workflowStepId))
|
||||
.map((workflowStepId) => {
|
||||
const result = workflowResultsById.get(workflowStepId);
|
||||
return {
|
||||
id: `workflow-${workflowStepId}`,
|
||||
name: resolveWorkflowStepName(workflowStepId, result),
|
||||
status: result ? mapWorkflowStatus(result) : "pending",
|
||||
source: "workflow" as const,
|
||||
phase: result?.phase ?? "pre-merge",
|
||||
};
|
||||
});
|
||||
const enabledWorkflowStepIds = new Set(task.enabledWorkflowSteps ?? []);
|
||||
/*
|
||||
FNXC:TaskCardWorkflowProgress 2026-06-29-15:05:
|
||||
@@ -119,6 +150,7 @@ export function getUnifiedTaskProgress(
|
||||
*/
|
||||
const recordedNodeItems: UnifiedTaskProgressItem[] = (task.workflowStepResults ?? [])
|
||||
.filter((result) => result.source === "node" && !enabledWorkflowStepIds.has(result.workflowStepId))
|
||||
.filter((result) => scope === "full" || !isNonImplementationWorkflowStepId(result.workflowStepId))
|
||||
.map((result) => ({
|
||||
id: `workflow-${result.workflowStepId}`,
|
||||
name: resolveWorkflowStepName(result.workflowStepId, result),
|
||||
@@ -130,9 +162,16 @@ export function getUnifiedTaskProgress(
|
||||
/*
|
||||
FNXC:TaskCardWorkflowProgress 2026-06-29-00:41:
|
||||
Plan Review is a pre-execution optional step in the default stepwise Coding workflow, so task cards must show it before parsed implementation steps. End-of-work optional steps such as Code Review stay after implementation steps so the card order matches workflow execution order.
|
||||
|
||||
FNXC:TaskCardWorkflowProgress 2026-07-21-22:26:
|
||||
Implementation scope omits Plan Review / Code Review and other lane-owned gates, so the pre-execution reorder is only meaningful for the full pipeline view.
|
||||
*/
|
||||
const preExecutionWorkflowItems = workflowItems.filter((item) => item.id === "workflow-plan-review");
|
||||
const remainingWorkflowItems = workflowItems.filter((item) => item.id !== "workflow-plan-review");
|
||||
const preExecutionWorkflowItems = scope === "full"
|
||||
? workflowItems.filter((item) => item.id === "workflow-plan-review")
|
||||
: [];
|
||||
const remainingWorkflowItems = scope === "full"
|
||||
? workflowItems.filter((item) => item.id !== "workflow-plan-review")
|
||||
: workflowItems;
|
||||
const items = [...preExecutionWorkflowItems, ...stepItems, ...remainingWorkflowItems, ...recordedNodeItems];
|
||||
const total = items.length;
|
||||
const completed = items.filter((item) => isCompleted(item.status)).length;
|
||||
@@ -161,9 +200,72 @@ export function getRunningWorkflowStepLabel(
|
||||
return running?.name;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Lane-owned optional gates are header badges, not progress bullet-list rows. Plan Review badges on planning columns (triage / todo); Code Review and Browser Verification (and post-merge verification) badge on in-review. Each badge reuses the same startedAt-without-completedAt "running" semantics as the progress list.
|
||||
*/
|
||||
const PLANNING_LANE_COLUMNS = new Set(["triage", "todo"]);
|
||||
const REVIEW_LANE_COLUMNS = new Set(["in-review"]);
|
||||
|
||||
export interface RunningOptionalGateBadge {
|
||||
workflowStepId: string;
|
||||
/** Full step name for titles / a11y. */
|
||||
name: string;
|
||||
/** Compact card/list label. */
|
||||
label: string;
|
||||
/** Stable test id fragment after `card-` / `list-`. */
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function workflowStepIdFromProgressItemId(itemId: string): string {
|
||||
return itemId.startsWith("workflow-") ? itemId.slice("workflow-".length) : itemId;
|
||||
}
|
||||
|
||||
export function getRunningOptionalGateBadge(
|
||||
task: Pick<Task, "column" | "steps" | "enabledWorkflowSteps" | "workflowStepResults">,
|
||||
): RunningOptionalGateBadge | undefined {
|
||||
const running = getUnifiedTaskProgress(task).items.find(
|
||||
(item) => item.source === "workflow" && item.status === "running",
|
||||
);
|
||||
if (!running) return undefined;
|
||||
|
||||
const workflowStepId = workflowStepIdFromProgressItemId(running.id);
|
||||
if (!isNonImplementationWorkflowStepId(workflowStepId)) return undefined;
|
||||
|
||||
if (workflowStepId === "plan-review" || workflowStepId === "plan-replan") {
|
||||
if (!PLANNING_LANE_COLUMNS.has(task.column)) return undefined;
|
||||
return {
|
||||
workflowStepId,
|
||||
name: running.name,
|
||||
// Keep the established short "Reviewing" label for Plan Review (FN-7831).
|
||||
label: "Reviewing",
|
||||
testId: "reviewing",
|
||||
};
|
||||
}
|
||||
|
||||
if (!REVIEW_LANE_COLUMNS.has(task.column)) return undefined;
|
||||
if (
|
||||
workflowStepId !== "code-review"
|
||||
&& workflowStepId !== "browser-verification"
|
||||
&& workflowStepId !== "post-merge-verification"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
workflowStepId,
|
||||
name: running.name,
|
||||
label: running.name,
|
||||
testId: workflowStepId,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:00:
|
||||
FN-7831 requires task cards and list rows to show a distinct "Reviewing" badge only while the optional `plan-review` workflow step is actively running. Reuse the unified progress item status so every board surface follows the same startedAt-without-completedAt semantics as the progress list.
|
||||
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Kept as a thin plan-review predicate for Ready-badge suppression and older call sites; header rendering prefers getRunningOptionalGateBadge for all lane-owned gates.
|
||||
*/
|
||||
export function isPlanReviewRunning(task: Pick<Task, "steps" | "enabledWorkflowSteps" | "workflowStepResults">): boolean {
|
||||
return getUnifiedTaskProgress(task).items.some(
|
||||
|
||||
Reference in New Issue
Block a user