diff --git a/.changeset/plan-approval-banner-actions.md b/.changeset/plan-approval-banner-actions.md
new file mode 100644
index 0000000000..281d554167
--- /dev/null
+++ b/.changeset/plan-approval-banner-actions.md
@@ -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.
diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css
index 0a709a5644..0115856b14 100644
--- a/packages/dashboard/app/components/TaskDetailModal.css
+++ b/packages/dashboard/app/components/TaskDetailModal.css
@@ -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;
diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx
index b018e0433c..fe0a5c09e6 100644
--- a/packages/dashboard/app/components/TaskDetailModal.tsx
+++ b/packages/dashboard/app/components/TaskDetailModal.tsx
@@ -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.",
)}
+ {/*
+ 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 && (
+
+
+ {t("taskDetail.plan.approveBtn", "Approve Plan")}
+
+
+ {t("taskDetail.plan.rejectBtn", "Reject Plan")}
+
+
+ )}
)}
@@ -6769,10 +6784,10 @@ export function TaskDetailContent({
legacy rows with awaitingApprovalReason === "release-authorization"). */}
{isAwaitingApproval && workingTask.prompt && (
<>
-
+
{t("taskDetail.plan.approveBtn", "Approve Plan")}
-
+
{t("taskDetail.plan.rejectBtn", "Reject Plan")}
>
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx
index 850c63bdf5..a67c6c0d1e 100644
--- a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx
@@ -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(
{
/>,
);
- 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");