FN-8907: hide Promote for planning tasks
Prevent planning-stage task cards from exposing an action that cannot safely advance work. - Gate the Promote action and related cost-row layout on planning state. - Cover planning, approval, replan, and planned hold-card render paths. - Add a patch changeset for the dashboard behavior fix. Files changed: .changeset/fn-8907-promote-planning.md | 7 ++ packages/dashboard/app/components/TaskCard.tsx | 18 +++- .../__tests__/TaskCard.cost-badge.test.tsx | 3 +- .../__tests__/TaskCard.footer-wrap.test.tsx | 2 +- .../app/components/__tests__/TaskCard.test.tsx | 97 ++++++++++++++++++++-- 5 files changed, 116 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-8907 Fusion-Task-Lineage: d64aaa0a-6675-4c59-9b21-7fd82f20ecfa Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8907-promote-planning.md
Normal file
7
.changeset/fn-8907-promote-planning.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Hide Promote on board cards that are still being planned.
|
||||||
|
category: fix
|
||||||
|
dev: Gates the TaskCard Promote render path on existing planning signals.
|
||||||
@@ -1585,6 +1585,18 @@ function TaskCardComponent({
|
|||||||
two independent conditions disjoint.
|
two independent conditions disjoint.
|
||||||
*/
|
*/
|
||||||
const awaitingPlanning = task.awaitingPlanning ?? ((task.steps?.length ?? 0) === 0);
|
const awaitingPlanning = task.awaitingPlanning ?? ((task.steps?.length ?? 0) === 0);
|
||||||
|
/*
|
||||||
|
FNXC:TaskCardPromote 2026-08-09-19:00:
|
||||||
|
Post-U11, the hold column is also the planning lane, so Promote must not be offered while a card is unplanned, being planned, in Plan Review, or awaiting plan approval. That click is rejected as `unplanned-for-execution` and the force path would start implementation against an incomplete plan.
|
||||||
|
|
||||||
|
`awaitingPlanning` is absent from SSE payloads, so its step-count fallback deliberately matches the Ready / Queued to plan badge pair. `isAwaitingApproval` only applies on an intake-trait merged planning lane or for the `plan-review-replan-cap` reason.
|
||||||
|
*/
|
||||||
|
const isStillInPlanning = awaitingPlanning
|
||||||
|
|| task.status === "planning"
|
||||||
|
|| task.status === "needs-replan"
|
||||||
|
|| planReviewRunning
|
||||||
|
|| isAwaitingApproval;
|
||||||
|
const showPromoteAction = Boolean(onPromote) && !isStillInPlanning;
|
||||||
const showIdleTodoBadge = !isPaused
|
const showIdleTodoBadge = !isPaused
|
||||||
&& isHoldColumn
|
&& isHoldColumn
|
||||||
&& !visualStatus
|
&& !visualStatus
|
||||||
@@ -2161,7 +2173,7 @@ function TaskCardComponent({
|
|||||||
);
|
);
|
||||||
return (next?.id ?? "todo") as ColumnId;
|
return (next?.id ?? "todo") as ColumnId;
|
||||||
}, [taskMoveColumns, task.column]);
|
}, [taskMoveColumns, task.column]);
|
||||||
const shouldRenderActionRow = Boolean(onPromote) || showCreatePrQuickAction || showAddressPrFeedbackAction || showStartAction;
|
const shouldRenderActionRow = showPromoteAction || showCreatePrQuickAction || showAddressPrFeedbackAction || showStartAction;
|
||||||
|
|
||||||
const enterEditMode = useCallback((e?: React.MouseEvent) => {
|
const enterEditMode = useCallback((e?: React.MouseEvent) => {
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
@@ -3210,7 +3222,7 @@ function TaskCardComponent({
|
|||||||
&& Boolean(githubTrackedIssue);
|
&& Boolean(githubTrackedIssue);
|
||||||
const footerHasLeadingContent = Boolean(filesChangedButton)
|
const footerHasLeadingContent = Boolean(filesChangedButton)
|
||||||
|| (isGitHubImportedTask && !showLinkedIssueChipForImport);
|
|| (isGitHubImportedTask && !showLinkedIssueChipForImport);
|
||||||
const costBadgeBelowPromote = Boolean(onPromote && cardCostLabel);
|
const costBadgeBelowPromote = Boolean(showPromoteAction && cardCostLabel);
|
||||||
const costBadgeChip = cardCostLabel ? (
|
const costBadgeChip = cardCostLabel ? (
|
||||||
<span
|
<span
|
||||||
className="card-cost-indicator"
|
className="card-cost-indicator"
|
||||||
@@ -4340,7 +4352,7 @@ function TaskCardComponent({
|
|||||||
{isStarting ? t("tasks.starting", "Starting…") : t("tasks.start", "Start")}
|
{isStarting ? t("tasks.starting", "Starting…") : t("tasks.start", "Start")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onPromote && (
|
{showPromoteAction && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="card-promote-action card-send-back-btn"
|
className="card-promote-action card-send-back-btn"
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ function taskWithUsage(overrides: Partial<Task> = {}): Task {
|
|||||||
title: "Cost badge fixture",
|
title: "Cost badge fixture",
|
||||||
description: "",
|
description: "",
|
||||||
column: "todo",
|
column: "todo",
|
||||||
steps: [],
|
steps: [{ name: "Implement", status: "pending" }] as any,
|
||||||
|
awaitingPlanning: false,
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
tokenUsage: {
|
tokenUsage: {
|
||||||
inputTokens: 1_000_000,
|
inputTokens: 1_000_000,
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ describe("TaskCard footer wrapping (FN-5210)", () => {
|
|||||||
it("places workflow badges after footer and action rows in DOM order", () => {
|
it("places workflow badges after footer and action rows in DOM order", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
task={makeTask()}
|
task={makeTask({ awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
|
||||||
onOpenDetail={noop}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onOpenDetailWithTab={noop}
|
onOpenDetailWithTab={noop}
|
||||||
|
|||||||
@@ -6723,6 +6723,8 @@ describe("TaskCard", () => {
|
|||||||
const pricedTask = makeTask({
|
const pricedTask = makeTask({
|
||||||
id: "FN-8324",
|
id: "FN-8324",
|
||||||
column: "todo",
|
column: "todo",
|
||||||
|
awaitingPlanning: false,
|
||||||
|
steps: [{ name: "Implement", status: "pending" }] as any,
|
||||||
tokenUsage: {
|
tokenUsage: {
|
||||||
inputTokens: 1_000_000,
|
inputTokens: 1_000_000,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
@@ -7879,7 +7881,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
try {
|
try {
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
task={makeTask({ id: "FN-777", column: "todo" })}
|
task={makeTask({ id: "FN-777", column: "todo", awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
|
||||||
onOpenDetail={noop}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -7910,7 +7912,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
task={makeTask({ id: "FN-781", column: "todo" })}
|
task={makeTask({ id: "FN-781", column: "todo", awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
|
||||||
onOpenDetail={noop}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -7932,7 +7934,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
|
|
||||||
const soloRender = render(
|
const soloRender = render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
task={makeTask({ id: "FN-782", column: "todo" })}
|
task={makeTask({ id: "FN-782", column: "todo", awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
|
||||||
onOpenDetail={noop}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -7946,7 +7948,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
task={makeTask({ id: "FN-783", column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
|
task={makeTask({ id: "FN-783", column: "in-review", paused: false, userPaused: false, prInfo: undefined as any, awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
|
||||||
onOpenDetail={noop}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -7971,7 +7973,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
task={makeTask({ id: "FN-778", column: "todo" })}
|
task={makeTask({ id: "FN-778", column: "todo", awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
|
||||||
onOpenDetail={onOpenDetail}
|
onOpenDetail={onOpenDetail}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -7989,7 +7991,7 @@ describe("TaskCard mission badge", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
task={makeTask({ id: "FN-779", column: "todo" })}
|
task={makeTask({ id: "FN-779", column: "todo", awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
|
||||||
onOpenDetail={noop}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
@@ -8005,6 +8007,87 @@ describe("TaskCard mission badge", () => {
|
|||||||
expect(onPromote).not.toHaveBeenCalled();
|
expect(onPromote).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suppresses Promote for every planning state while retaining the planned capacity-hold action", () => {
|
||||||
|
let sequence = 0;
|
||||||
|
const makePromoteFixture = (overrides: Partial<Task> = {}) => makeTask({
|
||||||
|
id: `FN-8907-${sequence++}`,
|
||||||
|
title: `Promote fixture ${sequence}`,
|
||||||
|
column: "todo",
|
||||||
|
awaitingPlanning: false,
|
||||||
|
status: null as any,
|
||||||
|
steps: [{ name: "Implement", status: "pending" }] as any,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const renderPromoteFixture = (task: Task, taskColumnFlags: any, cost = false) => render(
|
||||||
|
<CostBadgeProvider value={{ enabled: cost }}>
|
||||||
|
<TaskCard task={task} taskColumnFlags={taskColumnFlags} onOpenDetail={noop} addToast={noop} onPromote={vi.fn().mockResolvedValue(undefined)} />
|
||||||
|
</CostBadgeProvider>,
|
||||||
|
);
|
||||||
|
const expectSuppressedWithControl = (planning: Partial<Task>, control: Partial<Task>, flags: any) => {
|
||||||
|
const planningTask = makePromoteFixture(planning);
|
||||||
|
const suppressed = renderPromoteFixture(planningTask, flags);
|
||||||
|
expect(screen.getByText(planningTask.title)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId(`card-promote-${planningTask.id}`)).toBeNull();
|
||||||
|
suppressed.unmount();
|
||||||
|
|
||||||
|
const controlTask = makePromoteFixture(control);
|
||||||
|
const positive = renderPromoteFixture(controlTask, flags);
|
||||||
|
expect(screen.getByText(controlTask.title)).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId(`card-promote-${controlTask.id}`)).toBeInTheDocument();
|
||||||
|
positive.unmount();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Harness self-check: planned capacity-held cards must reach the rendered Promote branch.
|
||||||
|
const readyTask = makePromoteFixture();
|
||||||
|
const ready = renderPromoteFixture(readyTask, { hold: true });
|
||||||
|
const readyPromote = screen.getByTestId(`card-promote-${readyTask.id}`);
|
||||||
|
expect(readyPromote).toHaveClass("card-promote-action");
|
||||||
|
expect(readyPromote).toHaveTextContent("Promote");
|
||||||
|
ready.unmount();
|
||||||
|
|
||||||
|
expectSuppressedWithControl({ awaitingPlanning: true }, { awaitingPlanning: false }, { hold: true });
|
||||||
|
expectSuppressedWithControl({ awaitingPlanning: undefined, steps: [] }, { awaitingPlanning: undefined, steps: [{ name: "Implement", status: "pending" }] as any }, { hold: true });
|
||||||
|
expectSuppressedWithControl({ status: "planning" as any }, { status: null as any }, { hold: true });
|
||||||
|
expectSuppressedWithControl({ status: "needs-replan" as any }, { status: null as any }, { hold: true });
|
||||||
|
expectSuppressedWithControl({
|
||||||
|
enabledWorkflowSteps: ["plan-review"],
|
||||||
|
workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending", startedAt: "2026-08-09T00:00:00Z" }],
|
||||||
|
}, {
|
||||||
|
enabledWorkflowSteps: ["plan-review"],
|
||||||
|
workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending" }],
|
||||||
|
}, { hold: true });
|
||||||
|
expectSuppressedWithControl({ awaitingPlanning: true }, { awaitingPlanning: false }, undefined);
|
||||||
|
|
||||||
|
/* isTaskAwaitingPlanApproval requires intake unless the replan-cap reason is set. */
|
||||||
|
expectSuppressedWithControl({ status: "awaiting-approval" as any }, { status: null as any }, { hold: true, intake: true });
|
||||||
|
expectSuppressedWithControl(
|
||||||
|
{ status: "awaiting-approval" as any, awaitingApprovalReason: "plan-review-replan-cap" as any },
|
||||||
|
{ status: "awaiting-approval" as any, awaitingApprovalReason: undefined },
|
||||||
|
{ hold: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Promote/cost-row CSS is media-query-only, so DOM absence covers desktop and mobile alike.
|
||||||
|
const pricedPlanning = makePromoteFixture({ awaitingPlanning: true, tokenUsage: {
|
||||||
|
inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000,
|
||||||
|
firstUsedAt: "2026-08-09T00:00:00Z", lastUsedAt: "2026-08-09T00:00:00Z", modelProvider: "openai", modelId: "gpt-5-mini",
|
||||||
|
} });
|
||||||
|
const pricedSuppressed = renderPromoteFixture(pricedPlanning, { hold: true }, true);
|
||||||
|
expect(screen.getByText(pricedPlanning.title)).toBeInTheDocument();
|
||||||
|
expect(pricedSuppressed.container.querySelector(".card-action-row")).toBeNull();
|
||||||
|
expect(pricedSuppressed.container.querySelector(".card-promote-action")).toBeNull();
|
||||||
|
expect(pricedSuppressed.container.querySelector(".card-send-back-btn")).toBeNull();
|
||||||
|
expect(pricedSuppressed.container.querySelector(".card-promote-cost-row")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Promote task" })).toBeNull();
|
||||||
|
expect(pricedSuppressed.container.querySelectorAll(".card-cost-indicator")).toHaveLength(1);
|
||||||
|
expect(pricedSuppressed.container.querySelector(".card-cost-indicator")?.closest(".card-footer-row")).not.toBeNull();
|
||||||
|
pricedSuppressed.unmount();
|
||||||
|
|
||||||
|
const pricedControl = makePromoteFixture({ tokenUsage: pricedPlanning.tokenUsage });
|
||||||
|
const pricedPromotable = renderPromoteFixture(pricedControl, { hold: true }, true);
|
||||||
|
expect(screen.getByTestId(`card-promote-${pricedControl.id}`)).toBeInTheDocument();
|
||||||
|
expect(pricedPromotable.container.querySelector(".card-promote-cost-row .card-cost-indicator")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not render a promote action when onPromote is omitted", () => {
|
it("does not render a promote action when onPromote is omitted", () => {
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
@@ -8639,6 +8722,8 @@ describe("TaskCard trailing-row layout (FN-8631)", () => {
|
|||||||
task={makeTask({
|
task={makeTask({
|
||||||
id: `FN-cost-${width}`,
|
id: `FN-cost-${width}`,
|
||||||
column: "todo",
|
column: "todo",
|
||||||
|
awaitingPlanning: false,
|
||||||
|
steps: [{ name: "Implement", status: "pending" }] as any,
|
||||||
tokenUsage: { inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000, firstUsedAt: "2026-01-01T00:00:00Z", lastUsedAt: "2026-01-01T00:00:00Z", modelProvider: "openai", modelId: "gpt-5-mini" },
|
tokenUsage: { inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000, firstUsedAt: "2026-01-01T00:00:00Z", lastUsedAt: "2026-01-01T00:00:00Z", modelProvider: "openai", modelId: "gpt-5-mini" },
|
||||||
} as Partial<Task>)}
|
} as Partial<Task>)}
|
||||||
onOpenDetail={noop}
|
onOpenDetail={noop}
|
||||||
|
|||||||
Reference in New Issue
Block a user