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;
}
.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 {
display: flex;
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.",
)}
</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 className="detail-meta">
@@ -6769,10 +6784,10 @@ export function TaskDetailContent({
legacy rows with awaitingApprovalReason === "release-authorization"). */}
{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")}
</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")}
</button>
</>

View File

@@ -454,10 +454,11 @@ describe("TaskDetailModal", () => {
});
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(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
@@ -472,12 +473,39 @@ describe("TaskDetailModal", () => {
/>,
);
expect(screen.getByText("Approve Plan")).toBeTruthy();
expect(screen.getByText("Reject Plan")).toBeTruthy();
const { approvePlan, rejectPlan } = await import("../../api");
const mockApprovePlan = vi.mocked(approvePlan);
const mockRejectPlan = vi.mocked(rejectPlan);
mockApprovePlan.mockClear();
mockRejectPlan.mockClear();
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.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(/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");
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(/exhausted|without approving|stopped the replan loop/i)).toBeTruthy();
});
@@ -537,8 +567,10 @@ describe("TaskDetailModal", () => {
/>,
);
expect(screen.getByText("Approve Plan")).toBeTruthy();
expect(screen.getByText("Reject Plan")).toBeTruthy();
expect(screen.getByTestId("detail-plan-approval-banner-approve")).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();
});
@@ -584,6 +616,8 @@ describe("TaskDetailModal", () => {
expect(screen.queryByText("Approve 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", () => {
@@ -606,6 +640,7 @@ describe("TaskDetailModal", () => {
expect(screen.queryByText("Approve 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 () => {
@@ -632,7 +667,7 @@ describe("TaskDetailModal", () => {
/>,
);
fireEvent.click(screen.getByText("Approve Plan"));
fireEvent.click(screen.getByTestId("detail-plan-approval-footer-approve"));
await waitFor(() => {
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({
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(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(() => {
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(() => {
expect(addToast).toHaveBeenCalledWith("Server error", "error");