FN-8674: add plan approval banner actions

Place plan approval controls alongside the task approval message for faster operator action.

- Add approve and reject controls to the plan-approval banner.
- Reuse existing approval handlers and add coverage for banner and footer actions.
- Add responsive banner action styling and a patch changeset.

Files changed:
 .changeset/plan-approval-banner-actions.md         |  7 +++
 .../dashboard/app/components/TaskDetailModal.css   | 14 +++++
 .../dashboard/app/components/TaskDetailModal.tsx   | 19 ++++++-
 .../TaskDetailModal.definition-actions.test.tsx    | 59 +++++++++++++++++-----
 4 files changed, 85 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-8674

Fusion-Task-Lineage: 89c521c2-535d-4492-8b39-6e1517693218

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-31 23:53:38 -07:00
parent 713e9320b0
commit ec55889605
4 changed files with 85 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Place plan approval actions alongside the task approval message.
category: fix
dev: The task-detail approval banner now reuses the footer approval handlers and visibility gate.

View File

@@ -3727,6 +3727,20 @@ Reuse the near-duplicate banner layout; replan-cap escalations use a stronger wa
line-height: 1.45; line-height: 1.45;
} }
.detail-plan-approval-banner__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
margin-top: var(--space-xs);
}
@media (max-width: 768px) {
.detail-plan-approval-banner__actions {
flex-direction: column;
align-items: flex-start;
}
}
.detail-near-duplicate-banner__header { .detail-near-duplicate-banner__header {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -4736,6 +4736,21 @@ export function TaskDetailContent({
"This project's plan-approval settings require a human decision before work starts. Review the plan below, then Approve Plan to continue to Todo or Reject Plan to regenerate it.", "This project's plan-approval settings require a human decision before work starts. Review the plan below, then Approve Plan to continue to Todo or Reject Plan to regenerate it.",
)} )}
</p> </p>
{/*
FNXC:PlanApproval 2026-08-01-06:34:
Approval actions must sit beside the top approval message as well as in the persistent footer,
so an operator can act without scrolling through a long task body.
*/}
{workingTask.prompt && (
<div className="detail-plan-approval-banner__actions" data-testid="detail-plan-approval-banner-actions">
<button className="btn btn-primary btn-sm" data-testid="detail-plan-approval-banner-approve" onClick={handleApprovePlan}>
{t("taskDetail.plan.approveBtn", "Approve Plan")}
</button>
<button className="btn btn-danger btn-sm" data-testid="detail-plan-approval-banner-reject" onClick={handleRejectPlan}>
{t("taskDetail.plan.rejectBtn", "Reject Plan")}
</button>
</div>
)}
</div> </div>
)} )}
<div className="detail-meta"> <div className="detail-meta">
@@ -6769,10 +6784,10 @@ export function TaskDetailContent({
legacy rows with awaitingApprovalReason === "release-authorization"). */} legacy rows with awaitingApprovalReason === "release-authorization"). */}
{isAwaitingApproval && workingTask.prompt && ( {isAwaitingApproval && workingTask.prompt && (
<> <>
<button className="btn btn-primary btn-sm" onClick={handleApprovePlan}> <button className="btn btn-primary btn-sm" data-testid="detail-plan-approval-footer-approve" onClick={handleApprovePlan}>
{t("taskDetail.plan.approveBtn", "Approve Plan")} {t("taskDetail.plan.approveBtn", "Approve Plan")}
</button> </button>
<button className="btn btn-danger btn-sm" onClick={handleRejectPlan}> <button className="btn btn-danger btn-sm" data-testid="detail-plan-approval-footer-reject" onClick={handleRejectPlan}>
{t("taskDetail.plan.rejectBtn", "Reject Plan")} {t("taskDetail.plan.rejectBtn", "Reject Plan")}
</button> </button>
</> </>

View File

@@ -454,10 +454,11 @@ describe("TaskDetailModal", () => {
}); });
describe("Plan Approval UI", () => { describe("Plan Approval UI", () => {
it("shows Approve Plan and Reject Plan buttons for awaiting-approval tasks in triage", () => { it("shows Approve Plan and Reject Plan buttons for awaiting-approval tasks in triage", async () => {
render( render(
<TaskDetailModal <TaskDetailModal
task={makeTask({ task={makeTask({
id: "FN-001",
column: "triage", column: "triage",
status: "awaiting-approval", status: "awaiting-approval",
prompt: "# Task Spec", prompt: "# Task Spec",
@@ -472,12 +473,39 @@ describe("TaskDetailModal", () => {
/>, />,
); );
expect(screen.getByText("Approve Plan")).toBeTruthy(); const { approvePlan, rejectPlan } = await import("../../api");
expect(screen.getByText("Reject Plan")).toBeTruthy(); const mockApprovePlan = vi.mocked(approvePlan);
const mockRejectPlan = vi.mocked(rejectPlan);
mockApprovePlan.mockClear();
mockRejectPlan.mockClear();
const banner = screen.getByTestId("detail-plan-approval-banner"); const banner = screen.getByTestId("detail-plan-approval-banner");
const bannerActions = screen.getByTestId("detail-plan-approval-banner-actions");
const bannerApprove = screen.getByTestId("detail-plan-approval-banner-approve");
const bannerReject = screen.getByTestId("detail-plan-approval-banner-reject");
expect(banner.getAttribute("data-awaiting-approval-reason")).toBe("manual"); expect(banner.getAttribute("data-awaiting-approval-reason")).toBe("manual");
expect(banner.contains(bannerActions)).toBe(true);
expect(bannerActions.contains(bannerApprove)).toBe(true);
expect(bannerActions.contains(bannerReject)).toBe(true);
const approveButtons = screen.getAllByRole("button", { name: "Approve Plan" });
const rejectButtons = screen.getAllByRole("button", { name: "Reject Plan" });
expect(approveButtons).toHaveLength(2);
expect(rejectButtons).toHaveLength(2);
expect(approveButtons.some(button => !banner.contains(button))).toBe(true);
expect(rejectButtons.some(button => !banner.contains(button))).toBe(true);
expect(screen.getByText("Approval needed before implementation")).toBeTruthy(); expect(screen.getByText("Approval needed before implementation")).toBeTruthy();
expect(screen.getByText(/require a human decision before work starts/i)).toBeTruthy(); expect(screen.getByText(/require a human decision before work starts/i)).toBeTruthy();
fireEvent.click(bannerApprove);
await waitFor(() => {
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined);
});
fireEvent.click(bannerReject);
await waitFor(() => {
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001", undefined);
});
}); });
/* /*
@@ -504,10 +532,12 @@ describe("TaskDetailModal", () => {
/>, />,
); );
expect(screen.getByText("Approve Plan")).toBeTruthy();
expect(screen.getByText("Reject Plan")).toBeTruthy();
const banner = screen.getByTestId("detail-plan-approval-banner"); const banner = screen.getByTestId("detail-plan-approval-banner");
expect(banner.getAttribute("data-awaiting-approval-reason")).toBe("plan-review-replan-cap"); expect(banner.getAttribute("data-awaiting-approval-reason")).toBe("plan-review-replan-cap");
expect(banner.classList.contains("detail-plan-approval-banner--replan-cap")).toBe(true);
expect(banner.contains(screen.getByTestId("detail-plan-approval-banner-actions"))).toBe(true);
expect(banner.contains(screen.getByTestId("detail-plan-approval-banner-approve"))).toBe(true);
expect(banner.contains(screen.getByTestId("detail-plan-approval-banner-reject"))).toBe(true);
expect(screen.getByText("Approval needed: Plan Review did not converge")).toBeTruthy(); expect(screen.getByText("Approval needed: Plan Review did not converge")).toBeTruthy();
expect(screen.getByText(/exhausted|without approving|stopped the replan loop/i)).toBeTruthy(); expect(screen.getByText(/exhausted|without approving|stopped the replan loop/i)).toBeTruthy();
}); });
@@ -537,8 +567,10 @@ describe("TaskDetailModal", () => {
/>, />,
); );
expect(screen.getByText("Approve Plan")).toBeTruthy(); expect(screen.getByTestId("detail-plan-approval-banner-approve")).toBeTruthy();
expect(screen.getByText("Reject Plan")).toBeTruthy(); expect(screen.getByTestId("detail-plan-approval-banner-reject")).toBeTruthy();
expect(screen.getByTestId("detail-plan-approval-footer-approve")).toBeTruthy();
expect(screen.getByTestId("detail-plan-approval-footer-reject")).toBeTruthy();
expect(screen.queryByText(/Awaiting release authorization/i)).toBeNull(); expect(screen.queryByText(/Awaiting release authorization/i)).toBeNull();
}); });
@@ -584,6 +616,8 @@ describe("TaskDetailModal", () => {
expect(screen.queryByText("Approve Plan")).toBeNull(); expect(screen.queryByText("Approve Plan")).toBeNull();
expect(screen.queryByText("Reject Plan")).toBeNull(); expect(screen.queryByText("Reject Plan")).toBeNull();
expect(screen.queryByTestId("detail-plan-approval-banner")).toBeNull();
expect(screen.queryByTestId("detail-plan-approval-banner-actions")).toBeNull();
}); });
it("does not show approval buttons when task has no prompt", () => { it("does not show approval buttons when task has no prompt", () => {
@@ -606,6 +640,7 @@ describe("TaskDetailModal", () => {
expect(screen.queryByText("Approve Plan")).toBeNull(); expect(screen.queryByText("Approve Plan")).toBeNull();
expect(screen.queryByText("Reject Plan")).toBeNull(); expect(screen.queryByText("Reject Plan")).toBeNull();
expect(screen.queryByTestId("detail-plan-approval-banner-actions")).toBeNull();
}); });
it("calls approvePlan API and shows success toast when Approve Plan is clicked", async () => { it("calls approvePlan API and shows success toast when Approve Plan is clicked", async () => {
@@ -632,7 +667,7 @@ describe("TaskDetailModal", () => {
/>, />,
); );
fireEvent.click(screen.getByText("Approve Plan")); fireEvent.click(screen.getByTestId("detail-plan-approval-footer-approve"));
await waitFor(() => { await waitFor(() => {
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined); expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined);
@@ -668,7 +703,7 @@ describe("TaskDetailModal", () => {
/>, />,
); );
fireEvent.click(screen.getByText("Reject Plan")); fireEvent.click(screen.getByTestId("detail-plan-approval-footer-reject"));
expect(mockConfirm).toHaveBeenCalledWith({ expect(mockConfirm).toHaveBeenCalledWith({
title: "Reject Plan", title: "Reject Plan",
@@ -714,7 +749,7 @@ describe("TaskDetailModal", () => {
/>, />,
); );
fireEvent.click(screen.getByText("Reject Plan")); fireEvent.click(screen.getByTestId("detail-plan-approval-footer-reject"));
expect(mockConfirm).toHaveBeenCalled(); expect(mockConfirm).toHaveBeenCalled();
expect(mockRejectPlan).not.toHaveBeenCalled(); expect(mockRejectPlan).not.toHaveBeenCalled();
@@ -747,7 +782,7 @@ describe("TaskDetailModal", () => {
/>, />,
); );
fireEvent.click(screen.getByText("Approve Plan")); fireEvent.click(screen.getByTestId("detail-plan-approval-footer-approve"));
await waitFor(() => { await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Network error", "error"); expect(addToast).toHaveBeenCalledWith("Network error", "error");
@@ -782,7 +817,7 @@ describe("TaskDetailModal", () => {
/>, />,
); );
fireEvent.click(screen.getByText("Reject Plan")); fireEvent.click(screen.getByTestId("detail-plan-approval-footer-reject"));
await waitFor(() => { await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Server error", "error"); expect(addToast).toHaveBeenCalledWith("Server error", "error");