feat(KB-007): add manual plan approval setting

- Add requirePlanApproval setting to core types and dashboard config
- Add approve-plan and reject-plan API endpoints with handlers
- Add approve/reject UI buttons to TaskDetailModal for awaiting-approval tasks
- Update triage processor to check requirePlanApproval and set awaiting-approval status
- Add comprehensive tests for API endpoints, UI components, and triage logic
- Update AGENTS.md with documentation for the new setting
This commit is contained in:
gsxdsm
2026-03-29 19:23:30 -07:00
parent 9060979a15
commit aa8a78f023
12 changed files with 770 additions and 7 deletions

View File

@@ -203,6 +203,20 @@ export function SettingsModal({ onClose, addToast, initialSection }: SettingsMod
{prefixError && <small className="field-error">{prefixError}</small>}
{!prefixError && <small>Prefix for new task IDs (e.g. KB, PROJ)</small>}
</div>
<div className="form-group">
<label htmlFor="requirePlanApproval" className="checkbox-label">
<input
id="requirePlanApproval"
type="checkbox"
checked={form.requirePlanApproval || false}
onChange={(e) =>
setForm((f) => ({ ...f, requirePlanApproval: e.target.checked }))
}
/>
Require plan approval
</label>
<small>When enabled, AI-generated task specifications require manual approval before moving to Todo</small>
</div>
</>
);
case "model": {

View File

@@ -3,7 +3,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo } from "@kb/core";
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@kb/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, requestSpecRevision } from "../api";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, requestSpecRevision, approvePlan, rejectPlan } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";
import { AgentLogViewer } from "./AgentLogViewer";
@@ -172,6 +172,27 @@ export function TaskDetailModal({
}
}, [task.id, task.paused, onClose, addToast]);
const handleApprovePlan = useCallback(async () => {
try {
await approvePlan(task.id);
addToast(`Plan approved — ${task.id} moved to Todo`, "success");
onClose();
} catch (err: any) {
addToast(err.message, "error");
}
}, [task.id, onClose, addToast]);
const handleRejectPlan = useCallback(async () => {
if (!confirm("Reject this plan? The specification will be discarded and regenerated.")) return;
try {
await rejectPlan(task.id);
addToast(`Plan rejected — ${task.id} returned to Triage for re-specification`, "info");
onClose();
} catch (err: any) {
addToast(err.message, "error");
}
}, [task.id, onClose, addToast]);
const uploadFile = useCallback(async (file: File) => {
setUploading(true);
try {
@@ -639,6 +660,17 @@ export function TaskDetailModal({
{task.paused ? "Unpause" : "Pause"}
</button>
)}
{/* Approve/Reject Plan buttons for tasks awaiting approval */}
{task.column === "triage" && task.status === "awaiting-approval" && task.prompt && (
<>
<button className="btn btn-primary btn-sm" onClick={handleApprovePlan}>
Approve Plan
</button>
<button className="btn btn-danger btn-sm" onClick={handleRejectPlan}>
Reject Plan
</button>
</>
)}
<div style={{ flex: 1 }} />
{task.column === "in-review" ? (
<>

View File

@@ -10,6 +10,8 @@ vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
fetchAgentLogs: vi.fn().mockResolvedValue([]),
requestSpecRevision: vi.fn().mockResolvedValue({}),
approvePlan: vi.fn().mockResolvedValue({}),
rejectPlan: vi.fn().mockResolvedValue({}),
}));
vi.mock("../../hooks/useAgentLogs", () => ({
@@ -1536,4 +1538,271 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".spec-editor")).toBeTruthy();
});
});
describe("Plan Approval UI", () => {
it("shows Approve Plan and Reject Plan buttons for awaiting-approval tasks in triage", () => {
render(
<TaskDetailModal
task={makeTask({
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Approve Plan")).toBeTruthy();
expect(screen.getByText("Reject Plan")).toBeTruthy();
});
it("does not show approval buttons when task is not in triage", () => {
render(
<TaskDetailModal
task={makeTask({
column: "todo",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Approve Plan")).toBeNull();
expect(screen.queryByText("Reject Plan")).toBeNull();
});
it("does not show approval buttons when task does not have awaiting-approval status", () => {
render(
<TaskDetailModal
task={makeTask({
column: "triage",
status: "specifying",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Approve Plan")).toBeNull();
expect(screen.queryByText("Reject Plan")).toBeNull();
});
it("does not show approval buttons when task has no prompt", () => {
render(
<TaskDetailModal
task={makeTask({
column: "triage",
status: "awaiting-approval",
prompt: "",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Approve Plan")).toBeNull();
expect(screen.queryByText("Reject Plan")).toBeNull();
});
it("calls approvePlan API and shows success toast when Approve Plan is clicked", async () => {
const { approvePlan } = await import("../../api");
const mockApprovePlan = vi.mocked(approvePlan);
const addToast = vi.fn();
const onClose = vi.fn();
render(
<TaskDetailModal
task={makeTask({
id: "KB-001",
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Approve Plan"));
await waitFor(() => {
expect(mockApprovePlan).toHaveBeenCalledWith("KB-001");
});
expect(addToast).toHaveBeenCalledWith("Plan approved — KB-001 moved to Todo", "success");
expect(onClose).toHaveBeenCalled();
});
it("calls rejectPlan API and shows success toast when Reject Plan is confirmed", async () => {
const { rejectPlan } = await import("../../api");
const mockRejectPlan = vi.mocked(rejectPlan);
const addToast = vi.fn();
const onClose = vi.fn();
// Mock confirm to return true
const originalConfirm = window.confirm;
window.confirm = vi.fn(() => true);
render(
<TaskDetailModal
task={makeTask({
id: "KB-001",
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Reject Plan"));
expect(window.confirm).toHaveBeenCalledWith(
"Reject this plan? The specification will be discarded and regenerated."
);
await waitFor(() => {
expect(mockRejectPlan).toHaveBeenCalledWith("KB-001");
});
expect(addToast).toHaveBeenCalledWith(
"Plan rejected — KB-001 returned to Triage for re-specification",
"info"
);
expect(onClose).toHaveBeenCalled();
window.confirm = originalConfirm;
});
it("does not call rejectPlan API when Reject Plan is cancelled", async () => {
const { rejectPlan } = await import("../../api");
const mockRejectPlan = vi.mocked(rejectPlan);
mockRejectPlan.mockClear(); // Clear any previous calls
const addToast = vi.fn();
// Mock confirm to return false
const originalConfirm = window.confirm;
window.confirm = vi.fn(() => false);
render(
<TaskDetailModal
task={makeTask({
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Reject Plan"));
expect(window.confirm).toHaveBeenCalled();
expect(mockRejectPlan).not.toHaveBeenCalled();
expect(addToast).not.toHaveBeenCalled();
window.confirm = originalConfirm;
});
it("shows error toast when approvePlan fails", async () => {
const { approvePlan } = await import("../../api");
const mockApprovePlan = vi.mocked(approvePlan);
mockApprovePlan.mockRejectedValueOnce(new Error("Network error"));
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({
id: "KB-001",
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Approve Plan"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Network error", "error");
});
});
it("shows error toast when rejectPlan fails", async () => {
const { rejectPlan } = await import("../../api");
const mockRejectPlan = vi.mocked(rejectPlan);
mockRejectPlan.mockRejectedValueOnce(new Error("Server error"));
const addToast = vi.fn();
// Mock confirm to return true
const originalConfirm = window.confirm;
window.confirm = vi.fn(() => true);
render(
<TaskDetailModal
task={makeTask({
id: "KB-001",
column: "triage",
status: "awaiting-approval",
prompt: "# Task Spec",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByText("Reject Plan"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Server error", "error");
});
window.confirm = originalConfirm;
});
});
});