FN-7382: add triage plan auto-approve toggle

Add a board-column shortcut for toggling project plan auto-approval.

- Surface an Auto-approve plan switch in Triage and workflow intake/hold column menus.
- Wire the dashboard setting hook to persist planApprovalMode between workflow and auto-approve-all with optimistic rollback.
- Cover legacy and workflow board propagation plus column menu behavior with tests.
- Document the shortcut and add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7382-triage-auto-approve-toggle.md   |   7 ++
 docs/dashboard-guide.md                            |   2 +
 docs/settings-reference.md                         |   2 +-
 packages/dashboard/app/App.tsx                     |   4 +
 packages/dashboard/app/components/Board.tsx        |   7 +-
 packages/dashboard/app/components/Column.tsx       |  35 ++++++-
 .../app/components/__tests__/Board.test.tsx        |  55 +++++++++-
 .../app/components/__tests__/Column.test.tsx       |  75 ++++++++++++++
 .../app/components/dashboard/MainContent.tsx       |   6 ++
 .../dashboard/app/components/dashboard/types.ts    |   2 +
 .../app/hooks/__tests__/useAppSettings.test.ts     | 114 +++++++++++++++++++++
 packages/dashboard/app/hooks/useAppSettings.ts     |  39 +++++++
 12 files changed, 343 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7382

Fusion-Task-Lineage: 1db96b50-3a8f-4074-b957-c30d1745aa23

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 11:01:50 -07:00
parent af6e671e72
commit ec0fa960d7
12 changed files with 343 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Add a Triage column shortcut for plan auto-approval.
category: feature
dev: Adds a Board column switch that mirrors project planApprovalMode=auto-approve-all.

View File

@@ -149,6 +149,8 @@ FNXC:TaskCardMobileSelection 2026-07-01-00:00: Mobile Board long-press is a task
<!-- FNXC:BoardDoneSorting 2026-06-29-00:00: The Done board column exposes a local descending sort selector so operators can review either latest completions or highest task IDs without changing other lifecycle columns. -->
<!-- FNXC:BoardDoneSorting 2026-06-29-20:28: Document both Done sort modes as descending-only and Done-column-only so legacy Done and workflow complete-lane operators understand the selector does not change other lifecycle columns. -->
<!-- FNXC:BoardDoneActions 2026-06-30-00:00: Done/complete column sort choices and Archive All Done now live in the column actions dropdown, so docs must point operators to the menu instead of separate header controls. -->
<!-- FNXC:PlanApproval 2026-07-01-08:47: Operators need the Board Triage/intake column action menu documented as a binary shortcut for project plan auto-approval while Settings remains the full workflow/auto-approve/require-all editor. -->
- The Triage column actions dropdown includes **Auto-approve plan**. Turning it on sets the project plan approval mode to auto-approve all planned tasks; turning it off returns to the workflow/default plan approval behavior. In workflow-mode Boards, the same switch appears on intake/hold planning columns and on the equivalent **All workflows** aggregate intake column. Use Settings → Merge for the full three-state project control, including **Require approval for all tasks**.
- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` defaults to most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) and can be switched from the Done/complete column actions dropdown to descending task ID. In workflow mode, non-archived columns marked with the `complete` flag use the same Done menu items even when their column ID or label is customized.
- Done-column sorting has two descending modes: **Completion date (newest first)** keeps the default completion-time order, while **Task ID (newest first)** places the highest numeric task IDs first. The sort actions are only shown in Done/complete column action menus, including custom workflow completion lanes; Archive All Done lives in the same menu when available.
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.

View File

@@ -397,7 +397,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
| `allowAbsoluteFileBrowserPaths` | `boolean` | `false` | Project-scoped Settings → General toggle for the workspace file browser. When enabled, slash-prefixed paths such as `/tmp` can be listed/read/written/downloaded through workspace file-browser routes while keeping existing file-size, binary, type, null-byte, traversal, and permission checks. Windows drive-letter paths remain blocked, and task-local file routes, memory APIs, worktree-copy validation, plugin bundle paths, and other validators are unchanged. |
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
| `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"workflow"` | Project-scoped override for the manual planning approval gate. `"workflow"` preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every successfully specified task to `todo` without manual plan approval even when the selected workflow or stored workflow setting has `requirePlanApproval: true`; `"require-all"` parks every specified task at `status: "awaiting-approval"` regardless of workflow settings. This does not disable Workflow Plan Review, release authorization, or other non-plan safety gates. |
| `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"workflow"` | Project-scoped override for the manual planning approval gate. `"workflow"` preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every successfully specified task to `todo` without manual plan approval even when the selected workflow or stored workflow setting has `requirePlanApproval: true`; `"require-all"` parks every specified task at `status: "awaiting-approval"` regardless of workflow settings. Settings → Merge remains the full three-state editor; the Board Triage/intake **Auto-approve plan** switch is a binary shortcut for `"auto-approve-all"` vs `"workflow"`. This does not disable Workflow Plan Review, release authorization, or other non-plan safety gates. |
| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. |
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |

View File

@@ -551,6 +551,7 @@ function AppInner() {
maxConcurrent,
autoMerge,
mergeStrategy,
planAutoApproveEnabled,
showWorktreeGrouping,
globalPaused,
isTestMode,
@@ -575,6 +576,7 @@ function AppInner() {
goalsEnabled,
setQuickChatButtonModeImmediate,
toggleAutoMerge,
togglePlanAutoApprove,
refresh: refreshAppSettings,
} = useAppSettings(currentProject?.id);
@@ -1219,6 +1221,7 @@ function AppInner() {
prAuthAvailable,
autoMerge,
mergeStrategy,
planAutoApproveEnabled,
settingsLoaded,
taskDetailChatFirst,
skillsEnabled,
@@ -1270,6 +1273,7 @@ function AppInner() {
subtaskBreakdownEnabled,
openSubtaskBreakdownWithNav,
toggleAutoMerge,
togglePlanAutoApprove,
globalPaused,
updateTask,
retryTask,

View File

@@ -45,6 +45,8 @@ interface BoardProps {
/** Project merge strategy passed to Board-owned card context menus. */
mergeStrategy?: string;
onToggleAutoMerge: () => void;
planAutoApproveEnabled: boolean;
onTogglePlanAutoApprove: () => void;
globalPaused?: boolean;
onUpdateTask?: (
id: string,
@@ -156,7 +158,7 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
);
}
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
/*
FNXC:DoneColumnSorting 2026-06-29-16:57:
@@ -859,6 +861,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
mergeStrategy={mergeStrategy}
{...(((columnDef.flags.intake || columnDef.flags.hold) && !columnDef.flags.archived && !columnDef.flags.complete && !columnDef.flags.countsTowardWip && !columnDef.flags.mergeBlocker && !columnDef.flags.humanReview) ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
{...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, workflowOptions, defaultWorkflowId: boardWorkflows?.defaultWorkflowId ?? null, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
@@ -938,6 +941,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
mergeStrategy={mergeStrategy}
{...(((columnDef.flags.intake || columnDef.flags.hold) && !columnDef.flags.archived && !columnDef.flags.complete && !columnDef.flags.countsTowardWip && !columnDef.flags.mergeBlocker && !columnDef.flags.humanReview) ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
{...(isCreateColumn ? { workflowOptions, defaultWorkflowId: selectedWorkflow.id, onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
@@ -1045,6 +1049,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
mergeStrategy={mergeStrategy}
{...(col === "triage" ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}

View File

@@ -110,6 +110,8 @@ interface ColumnProps {
/** Project merge strategy for Task Detail-equivalent card context actions. */
mergeStrategy?: string;
onToggleAutoMerge?: () => void;
planAutoApproveEnabled?: boolean;
onTogglePlanAutoApprove?: () => void;
globalPaused?: boolean;
onUpdateTask?: (
id: string,
@@ -195,7 +197,7 @@ interface ColumnProps {
getDraggingTaskId?: () => string | null;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection
@@ -571,8 +573,13 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
const isDoneSortColumn = workflowMode ? columnFlags?.complete === true && columnFlags?.archived !== true : column === "done";
const showDoneSortControl = isDoneSortColumn && doneSortMode !== undefined && !!onDoneSortModeChange;
const showDoneArchiveAction = isDoneSortColumn && !!onArchiveAllDone;
/*
FNXC:PlanApproval 2026-07-01-08:44:
Triage and workflow intake/hold column actions need a Board shortcut that mirrors the project auto-approve plan override without replacing Settings modal's full workflow/auto-approve/require-all editor.
*/
const hasPlanAutoApproveAction = !!onTogglePlanAutoApprove;
const hasDoneMenuActions = showDoneSortControl || showDoneArchiveAction;
const hasColumnMenu = hasColumnBulkActions || hasDoneMenuActions;
const hasColumnMenu = hasColumnBulkActions || hasDoneMenuActions || hasPlanAutoApproveAction;
const doneSortControlLabel = t("column.doneSortControlLabel", "Sort Done tasks");
const doneSortOptions: Array<{ mode: DoneColumnSortMode; label: string }> = [
{ mode: "completion-date-desc", label: t("column.doneSortCompletionDateDesc", "Completion date (newest first)") },
@@ -584,6 +591,11 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
setIsMenuOpen(false);
}, [onDoneSortModeChange]);
const handlePlanAutoApproveToggle = useCallback(() => {
onTogglePlanAutoApprove?.();
setIsMenuOpen(false);
}, [onTogglePlanAutoApprove]);
const handleArchiveAll = useCallback(async () => {
setIsMenuOpen(false);
if (!onArchiveAllDone) return;
@@ -664,6 +676,25 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
</button>
{isMenuOpen && (
<div className="column-menu-popover" role="menu">
{hasPlanAutoApproveAction && (
<label className="column-menu-item auto-merge-toggle" role="menuitemcheckbox" aria-checked={!!planAutoApproveEnabled}>
<span className="column-menu-item-row">
<input
type="checkbox"
checked={!!planAutoApproveEnabled}
onChange={handlePlanAutoApproveToggle}
aria-label={t("column.planAutoApproveLabel", "Auto-approve plan")}
/>
<span className="toggle-slider" aria-hidden="true" />
<span>{t("column.planAutoApproveLabel", "Auto-approve plan")}</span>
</span>
<span className="column-menu-item-hint">
{planAutoApproveEnabled
? t("column.planAutoApproveOnHint", "On bypasses manual plan approval for this project")
: t("column.planAutoApproveOffHint", "Off uses the workflow/default plan approval setting")}
</span>
</label>
)}
{showDoneSortControl && (
<div className="column-menu-group" role="group" aria-label={doneSortControlLabel}>
{doneSortOptions.map((option) => (

View File

@@ -59,6 +59,8 @@ vi.mock("../Column", () => ({
onQuickCreate,
onNewTask,
onToggleAutoMerge,
planAutoApproveEnabled,
onTogglePlanAutoApprove,
onArchiveAllDone,
favoriteProviders,
favoriteModels,
@@ -83,6 +85,8 @@ vi.mock("../Column", () => ({
onQuickCreate?: unknown;
onNewTask?: unknown;
onToggleAutoMerge?: () => void;
planAutoApproveEnabled?: boolean;
onTogglePlanAutoApprove?: () => void;
onArchiveAllDone?: unknown;
favoriteProviders?: string[];
favoriteModels?: string[];
@@ -101,7 +105,7 @@ vi.mock("../Column", () => ({
}) => {
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
return (
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-workflow-badges={JSON.stringify(Object.fromEntries(taskWorkflowBadges ?? new Map()))} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-done-sort-mode={doneSortMode ?? ""} data-has-done-sort-handler={onDoneSortModeChange ? "yes" : "no"} data-workflow-id={workflowId ?? ""} data-workflow-options={JSON.stringify((workflowOptions ?? []).map((workflow) => workflow.id))} data-default-workflow-id={defaultWorkflowId ?? ""} data-column-display-name={columnDisplayName ?? ""} data-has-can-drop={canDropTask ? "yes" : "no"} data-has-planning={onPlanningMode ? "yes" : "no"} data-has-subtask={onSubtaskBreakdown ? "yes" : "no"}>
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-workflow-badges={JSON.stringify(Object.fromEntries(taskWorkflowBadges ?? new Map()))} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-plan-auto-approve-toggle={onTogglePlanAutoApprove ? "yes" : "no"} data-plan-auto-approve-enabled={planAutoApproveEnabled ? "true" : "false"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-done-sort-mode={doneSortMode ?? ""} data-has-done-sort-handler={onDoneSortModeChange ? "yes" : "no"} data-workflow-id={workflowId ?? ""} data-workflow-options={JSON.stringify((workflowOptions ?? []).map((workflow) => workflow.id))} data-default-workflow-id={defaultWorkflowId ?? ""} data-column-display-name={columnDisplayName ?? ""} data-has-can-drop={canDropTask ? "yes" : "no"} data-has-planning={onPlanningMode ? "yes" : "no"} data-has-subtask={onSubtaskBreakdown ? "yes" : "no"}>
{onQuickCreate ? (
<button type="button" data-testid={`mock-quick-create-${column}`} onClick={() => void (onQuickCreate as (input: { description: string; column?: string; workflowId?: string }) => Promise<unknown>)({ description: `Create from ${column}`, column, workflowId: "wf-custom" })}>
quick-create-{column}
@@ -187,6 +191,8 @@ function createBoardProps(overrides = {}) {
onNewTask: noop,
autoMerge: true,
onToggleAutoMerge: noop,
planAutoApproveEnabled: false,
onTogglePlanAutoApprove: noop,
globalPaused: false,
onUpdateTask: undefined,
onArchiveTask: undefined,
@@ -1243,6 +1249,53 @@ describe("Board", () => {
expect(screen.getByTestId("column-idea").getAttribute("data-has-quick-create")).toBe("yes");
});
it("passes plan auto-approval toggle only to the legacy Triage column", () => {
renderBoard({ planAutoApproveEnabled: true });
expect(screen.getByTestId("column-triage").getAttribute("data-has-plan-auto-approve-toggle")).toBe("yes");
expect(screen.getByTestId("column-triage").getAttribute("data-plan-auto-approve-enabled")).toBe("true");
for (const col of COLUMNS.filter((column) => column !== "triage")) {
expect(screen.getByTestId(`column-${col}`).getAttribute("data-has-plan-auto-approve-toggle")).toBe("no");
}
});
it("passes plan auto-approval toggle to selected workflow intake and hold columns only", async () => {
const workflow = {
id: "wf-plan-columns",
name: "Plan columns",
columns: [
{ id: "idea", name: "Idea", flags: { intake: true } },
{ id: "hold", name: "Hold", flags: { hold: true } },
{ id: "work", name: "Work", flags: { countsTowardWip: true } },
{ id: "review", name: "Review", flags: { humanReview: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
};
enableFlag({ "FN-1": workflow.id }, [workflow]);
renderBoard({ tasks: [mkTask({ id: "FN-1", column: "idea" })], planAutoApproveEnabled: true });
await waitFor(() => expect(screen.getByTestId("column-idea")).toBeDefined());
expect(screen.getByTestId("column-idea").getAttribute("data-has-plan-auto-approve-toggle")).toBe("yes");
expect(screen.getByTestId("column-hold").getAttribute("data-has-plan-auto-approve-toggle")).toBe("yes");
expect(screen.getByTestId("column-work").getAttribute("data-has-plan-auto-approve-toggle")).toBe("no");
expect(screen.getByTestId("column-review").getAttribute("data-has-plan-auto-approve-toggle")).toBe("no");
expect(screen.getByTestId("column-done").getAttribute("data-has-plan-auto-approve-toggle")).toBe("no");
});
it("passes plan auto-approval toggle to all-workflows aggregate intake columns", async () => {
const projectId = "project-all-plan-columns";
enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW]);
window.localStorage.setItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId), ALL_WORKFLOWS_BOARD_VIEW_ID);
renderBoard({
projectId,
tasks: [mkTask({ id: "FN-1", column: "triage" })],
});
await waitFor(() => expect(screen.getByTestId("column-triage")).toBeDefined());
expect(screen.getByTestId("column-triage").getAttribute("data-has-plan-auto-approve-toggle")).toBe("yes");
expect(screen.getByTestId("column-in-progress").getAttribute("data-has-plan-auto-approve-toggle")).toBe("no");
});
it("passes auto-merge toggle to selected workflow human-review columns", async () => {
const workflow = {
...DEFAULT_WORKFLOW,

View File

@@ -651,6 +651,81 @@ describe("Column in-progress/in-review bulk actions", () => {
});
});
describe("Column plan auto-approval action", () => {
it.each([
["workflow", false],
["auto-approve-all", true],
["require-all", false],
] as const)("renders the Triage switch checked only for %s mode", async (_mode, enabled) => {
const user = userEvent.setup();
const onTogglePlanAutoApprove = vi.fn();
render(
<Column
{...defaultProps}
column="triage"
tasks={[]}
planAutoApproveEnabled={enabled}
onTogglePlanAutoApprove={onTogglePlanAutoApprove}
/>,
);
await user.click(screen.getByRole("button", { name: "Planning column actions" }));
const switchItem = screen.getByRole("menuitemcheckbox", { name: /Auto-approve plan/i });
expect(switchItem).toHaveAttribute("aria-checked", enabled ? "true" : "false");
expect(screen.getByText(enabled ? /On bypasses manual plan approval/i : /Off uses the workflow\/default/i)).toBeInTheDocument();
});
it("calls the plan auto-approval toggle exactly once and closes the menu", async () => {
const user = userEvent.setup();
const onTogglePlanAutoApprove = vi.fn();
render(
<Column
{...defaultProps}
column="triage"
tasks={[makeTask("FN-001")]}
planAutoApproveEnabled={false}
onTogglePlanAutoApprove={onTogglePlanAutoApprove}
/>,
);
await user.click(screen.getByRole("button", { name: "Planning column actions" }));
await user.click(screen.getByRole("checkbox", { name: "Auto-approve plan" }));
expect(onTogglePlanAutoApprove).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menu")).toBeNull();
});
it("coexists with workflow intake replan actions", async () => {
const user = userEvent.setup();
render(
<Column
{...defaultProps}
column={"intake" as ColumnType}
workflowMode
columnDisplayName="Intake"
columnFlags={{ intake: true }}
tasks={[{ ...makeTask("FN-002"), column: "intake" as ColumnType }]}
planAutoApproveEnabled={true}
onTogglePlanAutoApprove={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Intake column actions" }));
expect(screen.getByRole("menuitemcheckbox", { name: /Auto-approve plan/i })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /Replan All/i })).toBeInTheDocument();
});
it("does not leave an actions shell on non-Triage columns without actions", () => {
render(<Column {...defaultProps} column="done" tasks={[]} />);
expect(screen.queryByRole("button", { name: "Done column actions" })).toBeNull();
expect(screen.queryByRole("menuitemcheckbox", { name: /Auto-approve plan/i })).toBeNull();
});
});
describe("Column Done action menu", () => {
it("renders one accessible Done actions dropdown with sort choices and archive", async () => {
const user = userEvent.setup();

View File

@@ -72,6 +72,7 @@ export function MainContent({
prAuthAvailable,
autoMerge,
mergeStrategy,
planAutoApproveEnabled,
settingsLoaded,
taskDetailChatFirst,
skillsEnabled,
@@ -123,6 +124,7 @@ export function MainContent({
subtaskBreakdownEnabled,
openSubtaskBreakdownWithNav,
toggleAutoMerge,
togglePlanAutoApprove,
globalPaused,
updateTask,
retryTask,
@@ -710,6 +712,8 @@ export function MainContent({
autoMerge={autoMerge}
mergeStrategy={mergeStrategy}
onToggleAutoMerge={toggleAutoMerge}
planAutoApproveEnabled={planAutoApproveEnabled}
onTogglePlanAutoApprove={togglePlanAutoApprove}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onRetryTask={retryTask}
@@ -813,6 +817,8 @@ export function MainContent({
autoMerge={autoMerge}
mergeStrategy={mergeStrategy}
onToggleAutoMerge={toggleAutoMerge}
planAutoApproveEnabled={planAutoApproveEnabled}
onTogglePlanAutoApprove={togglePlanAutoApprove}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onRetryTask={retryTask}

View File

@@ -112,6 +112,7 @@ export interface MainContentProps {
prAuthAvailable: boolean;
autoMerge: boolean;
mergeStrategy: string;
planAutoApproveEnabled: boolean;
settingsLoaded: boolean;
taskDetailChatFirst: boolean;
skillsEnabled: boolean;
@@ -167,6 +168,7 @@ export interface MainContentProps {
subtaskBreakdownEnabled: boolean;
openSubtaskBreakdownWithNav: (description: string, workflowId?: string | null) => void;
toggleAutoMerge: () => Promise<void>;
togglePlanAutoApprove: () => Promise<void>;
globalPaused: boolean;
updateTask: (
id: string,

View File

@@ -7,6 +7,7 @@ vi.mock("../../api", () => ({
fetchConfig: vi.fn(),
fetchSettings: vi.fn(),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
}));
const mockFetchConfig = vi.mocked(api.fetchConfig);
@@ -31,6 +32,7 @@ describe("useAppSettings", () => {
staleHighFanoutBlockerAgeThresholdMs: 7200000,
showQuickChatFAB: false,
capacityRiskBannerEnabled: false,
planApprovalMode: "workflow",
} as never);
mockUpdateSettings.mockResolvedValue({} as never);
@@ -55,12 +57,124 @@ describe("useAppSettings", () => {
expect(result.current.quickChatCloseOnOutsideClick).toBe(true);
expect(result.current.capacityRiskBannerEnabled).toBe(false);
expect(result.current.capacityRiskTodoThreshold).toBe(20);
expect(result.current.planApprovalMode).toBe("workflow");
expect(result.current.planAutoApproveEnabled).toBe(false);
});
expect(mockFetchConfig).toHaveBeenCalledWith("proj_123");
expect(mockFetchSettings).toHaveBeenCalledWith("proj_123");
});
it.each([
[undefined, "workflow", false],
["workflow", "workflow", false],
["auto-approve-all", "auto-approve-all", true],
["require-all", "require-all", false],
] as const)("hydrates planApprovalMode %s as %s", async (apiMode, expectedMode, expectedEnabled) => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: true,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
...(apiMode === undefined ? {} : { planApprovalMode: apiMode }),
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.planApprovalMode).toBe(expectedMode);
expect(result.current.planAutoApproveEnabled).toBe(expectedEnabled);
});
});
it("optimistically enables plan auto-approval and persists to API", async () => {
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.planApprovalMode).toBe("workflow");
});
await act(async () => {
await result.current.togglePlanAutoApprove();
});
expect(result.current.planApprovalMode).toBe("auto-approve-all");
expect(result.current.planAutoApproveEnabled).toBe(true);
expect(mockUpdateSettings).toHaveBeenCalledWith({ planApprovalMode: "auto-approve-all" }, "proj_123");
});
it("turns off plan auto-approval by returning to workflow mode", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: true,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
planApprovalMode: "auto-approve-all",
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.planAutoApproveEnabled).toBe(true);
});
await act(async () => {
await result.current.togglePlanAutoApprove();
});
expect(result.current.planApprovalMode).toBe("workflow");
expect(result.current.planAutoApproveEnabled).toBe(false);
expect(mockUpdateSettings).toHaveBeenCalledWith({ planApprovalMode: "workflow" }, "proj_123");
});
it("replaces require-all only when plan auto-approval is explicitly enabled", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: true,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
planApprovalMode: "require-all",
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.planApprovalMode).toBe("require-all");
expect(result.current.planAutoApproveEnabled).toBe(false);
});
await act(async () => {
await result.current.togglePlanAutoApprove();
});
expect(result.current.planApprovalMode).toBe("auto-approve-all");
expect(mockUpdateSettings).toHaveBeenCalledWith({ planApprovalMode: "auto-approve-all" }, "proj_123");
});
it("rolls back optimistic plan auto-approval state when toggle update fails", async () => {
mockUpdateSettings.mockRejectedValueOnce(new Error("network"));
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.planApprovalMode).toBe("workflow");
});
await act(async () => {
await result.current.togglePlanAutoApprove();
});
expect(result.current.planApprovalMode).toBe("workflow");
expect(result.current.planAutoApproveEnabled).toBe(false);
expect(mockUpdateSettings).toHaveBeenCalledWith({ planApprovalMode: "auto-approve-all" }, "proj_123");
});
it("optimistically toggles autoMerge and persists to API", async () => {
const { result } = renderHook(() => useAppSettings("proj_123"));

View File

@@ -1,8 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchConfig, fetchSettings, updateSettings, updateGlobalSettings } from "../api";
import type { ProjectSettings } from "@fusion/core";
import { setAutoReloadEnabled } from "../versionCheck";
export type QuickChatButtonMode = "floating" | "footer" | "off";
export type PlanApprovalMode = NonNullable<ProjectSettings["planApprovalMode"]>;
/**
* Settings state and actions consumed by the dashboard App shell.
@@ -12,6 +14,8 @@ export interface UseAppSettingsResult {
rootDir: string;
autoMerge: boolean;
mergeStrategy: string;
planApprovalMode: PlanApprovalMode;
planAutoApproveEnabled: boolean;
showWorktreeGrouping: boolean;
testMode: boolean;
isTestMode: boolean;
@@ -39,6 +43,7 @@ export interface UseAppSettingsResult {
goalsEnabled: boolean;
autoReloadOnVersionChange: boolean;
toggleAutoMerge: () => Promise<void>;
togglePlanAutoApprove: () => Promise<void>;
toggleGlobalPause: () => Promise<void>;
toggleEnginePause: () => Promise<void>;
toggleShowQuickChatFAB: () => Promise<void>;
@@ -56,6 +61,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [rootDir, setRootDir] = useState<string>(".");
const [autoMerge, setAutoMerge] = useState(true);
const [mergeStrategy, setMergeStrategy] = useState("direct");
const [planApprovalMode, setPlanApprovalMode] = useState<PlanApprovalMode>("workflow");
const [showWorktreeGrouping, setShowWorktreeGrouping] = useState(false);
const [testMode, setTestMode] = useState(false);
const [isTestMode, setIsTestMode] = useState(false);
@@ -83,6 +89,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [goalsEnabled, setGoalsEnabled] = useState(true);
const [autoReloadOnVersionChange, setAutoReloadOnVersionChangeState] = useState(true);
const autoMergeRef = useRef(autoMerge);
const planApprovalModeRef = useRef<PlanApprovalMode>(planApprovalMode);
/**
* Fetches config and settings from the backend and updates local state.
@@ -107,6 +114,12 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
Board and List context menus need the project merge strategy before PR creation so manual PR projects can show Start PR Review with the same availability as Task Detail.
*/
setMergeStrategy(typeof settings.mergeStrategy === "string" ? settings.mergeStrategy : "direct");
const nextPlanApprovalMode: PlanApprovalMode =
settings.planApprovalMode === "auto-approve-all" || settings.planApprovalMode === "require-all"
? settings.planApprovalMode
: "workflow";
planApprovalModeRef.current = nextPlanApprovalMode;
setPlanApprovalMode(nextPlanApprovalMode);
setShowWorktreeGrouping(settings.showWorktreeGrouping === true);
const nextTestMode = settings.testMode === true;
const nextIsTestMode = nextTestMode || settings.defaultProvider?.trim().toLowerCase() === "mock";
@@ -170,6 +183,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setTaskDetailChatFirst(false);
setQuickChatCloseOnOutsideClick(true);
setDismissModalsOnOutsideClick(false);
setPlanApprovalMode("workflow");
setTodosEnabled(true);
setGoalsEnabled(true);
void refresh();
@@ -179,6 +193,10 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
autoMergeRef.current = autoMerge;
}, [autoMerge]);
useEffect(() => {
planApprovalModeRef.current = planApprovalMode;
}, [planApprovalMode]);
const toggleAutoMerge = useCallback(async () => {
const previousAutoMerge = autoMergeRef.current;
const nextAutoMerge = !previousAutoMerge;
@@ -193,6 +211,24 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
}
}, [projectId]);
/*
FNXC:PlanApproval 2026-07-01-08:37:
The Board Triage shortcut is a binary mirror of project planApprovalMode === "auto-approve-all". Settings modal remains the full three-state editor, so turning the Board switch off returns to "workflow" and "require-all" stays unchecked until an operator explicitly enables auto-approval.
*/
const togglePlanAutoApprove = useCallback(async () => {
const previousMode = planApprovalModeRef.current;
const nextMode: PlanApprovalMode = previousMode === "auto-approve-all" ? "workflow" : "auto-approve-all";
planApprovalModeRef.current = nextMode;
setPlanApprovalMode(nextMode);
try {
await updateSettings({ planApprovalMode: nextMode }, projectId);
} catch {
planApprovalModeRef.current = previousMode;
setPlanApprovalMode(previousMode);
}
}, [projectId]);
const toggleGlobalPause = useCallback(async () => {
const next = !globalPaused;
setGlobalPaused(next);
@@ -261,6 +297,8 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
rootDir,
autoMerge,
mergeStrategy,
planApprovalMode,
planAutoApproveEnabled: planApprovalMode === "auto-approve-all",
showWorktreeGrouping,
testMode,
isTestMode,
@@ -288,6 +326,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
goalsEnabled,
autoReloadOnVersionChange,
toggleAutoMerge,
togglePlanAutoApprove,
toggleGlobalPause,
toggleEnginePause,
toggleShowQuickChatFAB,