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:
gsxdsm
2026-08-09 12:17:03 -07:00
parent 29bb6d0dc2
commit e72d9da1f7
5 changed files with 116 additions and 11 deletions

View 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.

View File

@@ -1585,6 +1585,18 @@ function TaskCardComponent({
two independent conditions disjoint.
*/
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
&& isHoldColumn
&& !visualStatus
@@ -2161,7 +2173,7 @@ function TaskCardComponent({
);
return (next?.id ?? "todo") as ColumnId;
}, [taskMoveColumns, task.column]);
const shouldRenderActionRow = Boolean(onPromote) || showCreatePrQuickAction || showAddressPrFeedbackAction || showStartAction;
const shouldRenderActionRow = showPromoteAction || showCreatePrQuickAction || showAddressPrFeedbackAction || showStartAction;
const enterEditMode = useCallback((e?: React.MouseEvent) => {
e?.stopPropagation();
@@ -3210,7 +3222,7 @@ function TaskCardComponent({
&& Boolean(githubTrackedIssue);
const footerHasLeadingContent = Boolean(filesChangedButton)
|| (isGitHubImportedTask && !showLinkedIssueChipForImport);
const costBadgeBelowPromote = Boolean(onPromote && cardCostLabel);
const costBadgeBelowPromote = Boolean(showPromoteAction && cardCostLabel);
const costBadgeChip = cardCostLabel ? (
<span
className="card-cost-indicator"
@@ -4340,7 +4352,7 @@ function TaskCardComponent({
{isStarting ? t("tasks.starting", "Starting…") : t("tasks.start", "Start")}
</button>
)}
{onPromote && (
{showPromoteAction && (
<button
type="button"
className="card-promote-action card-send-back-btn"

View File

@@ -38,7 +38,8 @@ function taskWithUsage(overrides: Partial<Task> = {}): Task {
title: "Cost badge fixture",
description: "",
column: "todo",
steps: [],
steps: [{ name: "Implement", status: "pending" }] as any,
awaitingPlanning: false,
dependencies: [],
tokenUsage: {
inputTokens: 1_000_000,

View File

@@ -174,7 +174,7 @@ describe("TaskCard footer wrapping (FN-5210)", () => {
it("places workflow badges after footer and action rows in DOM order", () => {
const { container } = render(
<TaskCard
task={makeTask()}
task={makeTask({ awaitingPlanning: false, steps: [{ name: "Implement", status: "pending" }] as any })}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={noop}

View File

@@ -6723,6 +6723,8 @@ describe("TaskCard", () => {
const pricedTask = makeTask({
id: "FN-8324",
column: "todo",
awaitingPlanning: false,
steps: [{ name: "Implement", status: "pending" }] as any,
tokenUsage: {
inputTokens: 1_000_000,
outputTokens: 0,
@@ -7879,7 +7881,7 @@ describe("TaskCard mission badge", () => {
try {
render(
<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}
addToast={noop}
onPromote={onPromote}
@@ -7910,7 +7912,7 @@ describe("TaskCard mission badge", () => {
render(
<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}
addToast={noop}
onPromote={onPromote}
@@ -7932,7 +7934,7 @@ describe("TaskCard mission badge", () => {
const soloRender = render(
<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}
addToast={noop}
onPromote={onPromote}
@@ -7946,7 +7948,7 @@ describe("TaskCard mission badge", () => {
render(
<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}
addToast={noop}
onPromote={onPromote}
@@ -7971,7 +7973,7 @@ describe("TaskCard mission badge", () => {
render(
<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}
addToast={noop}
onPromote={onPromote}
@@ -7989,7 +7991,7 @@ describe("TaskCard mission badge", () => {
render(
<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}
addToast={noop}
onPromote={onPromote}
@@ -8005,6 +8007,87 @@ describe("TaskCard mission badge", () => {
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", () => {
render(
<TaskCard
@@ -8639,6 +8722,8 @@ describe("TaskCard trailing-row layout (FN-8631)", () => {
task={makeTask({
id: `FN-cost-${width}`,
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" },
} as Partial<Task>)}
onOpenDetail={noop}