FN-022: add workflow selection and start action

Add workflow-aware task creation controls and a guarded Start action for quick task intake.

- Add workflow selection and resolved board metadata loading to New Task modal.
- Support starting eligible tasks in their workflow destination with clear success or partial-failure feedback.
- Wire move-task handling through modal callers and cover workflow/start behavior with tests and documentation.

Files changed:
 .changeset/fn-022-new-task-workflow-start.md       |   7 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/components/AppModals.tsx    |   1 +
 packages/dashboard/app/components/NewTaskModal.tsx | 158 +++++++++++++++--
 .../dashboard/app/components/QuickEntryBox.tsx     |   5 +-
 packages/dashboard/app/components/TaskForm.tsx     |  28 ++-
 .../app/components/__tests__/AppModals.test.tsx    |  33 +++-
 .../app/components/__tests__/NewTaskModal.test.tsx | 192 ++++++++++++++++++++-
 .../app/utils/__tests__/quickAddStart.test.ts      |  16 +-
 packages/dashboard/app/utils/quickAddStart.ts      |  15 ++
 10 files changed, 441 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-022

Fusion-Task-Lineage: ce794b44-3c31-4bfc-a259-ecfd78531be1

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 00:44:39 +00:00
parent 4d7e7dba4c
commit 612195fde6
10 changed files with 441 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preserve New Task workflow choices and add a guarded Start action for manual-intake workflows.
category: fix
dev: Start uses server-derived manual-intake metadata and validated workflow move targets.

View File

@@ -492,7 +492,9 @@ Behavior:
Workflows define how a task moves through planning, execution, review, workflow steps, merge, and any custom graph policy. Most coding tasks can stay on the default Coding workflow, but task and board workflow controls can select a different built-in or custom workflow per task. For the built-in catalog and runtime semantics, see [Workflow Steps → Workflow overview](./workflow-steps.md#workflow-overview).
<!-- FNXC:NewTaskWorkflowDropdown 2026-06-30-18:52: The full New Task dialog workflow picker now matches the icon-rich workflow identity used in Board/List selectors while preserving create-time workflowId semantics. -->
When creating a task from the full **New Task** dialog, the **Workflow** advanced control opens a styled dropdown instead of a native select. Built-in workflows show the Fusion mark, custom workflows show their configured compact icon when present, **No workflow** remains the explicit opt-out, and leaving the picker untouched still inherits the project/default workflow. Opening the dialog while viewing a specific Board or List workflow preselects that workflow across lane, sidebar, keyboard-shortcut, and description-seeded entry points; opening from **All workflows** instead leaves the picker unset so the project default applies.
When creating a task from the full **New Task** dialog, the **Workflow** advanced control opens a styled dropdown instead of a native select. Built-in workflows show the Fusion mark, custom workflows show their configured compact icon when present, **No workflow** remains the explicit opt-out, and leaving the picker untouched still inherits the project/default workflow. Opening the dialog while viewing a specific Board or List workflow preselects that workflow across lane, sidebar, keyboard-shortcut, and description-seeded entry points; opening from **All workflows** instead leaves the picker unset so the project default applies. The selected workflow is preserved when you choose **Create** or acknowledge a duplicate warning.
Manual-intake workflows also expose **Start** beside **Create** only when the server-provided workflow metadata proves a safe working destination. Coding (Ideas) creates directly in its validated working lane; other manual workflows create in intake and then perform one validated move. Start reports tasks as queued for planning, not as already planning, and a failed follow-up move reports the created-but-not-started partial outcome without deleting the task.
<!-- FNXC:WorkflowOptionalSteps 2026-07-10-08:00: Operators can now toggle optional workflow steps from the task Edit form as well as the Workflow tab. Edit mode must load the task's resolved workflow catalog without re-seeding from defaultOn, preserving the task's persisted enabledWorkflowSteps until the operator explicitly toggles a step. -->
Optional workflow steps can be toggled from the task **Edit** form's **More options → Workflow Steps** control or from the task's **Workflow** tab. The edit form uses the task's resolved workflow and preserves the task's current stored selection when it opens; workflow-authored `defaultOn` values remain a create-time/runtime default, not an edit-form re-seed.

View File

@@ -483,6 +483,7 @@ export function AppModals({
onClose={closeNewTaskWithNav}
tasks={tasks}
onCreateTask={handleModalCreateWithOnboardingTracking}
onMoveTask={(taskId, column) => taskOperations.moveTask(taskId, column as Column)}
addToast={addToast}
projectId={projectId}
initialDescription={modalManager.newTaskInitialDescription ?? ""}

View File

@@ -2,7 +2,7 @@ import "./NewTaskModal.css";
import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { DEFAULT_TASK_PRIORITY, type Task, type TaskPriority, type ThinkingLevel } from "@fusion/core";
import { DEFAULT_TASK_PRIORITY, type ColumnId, type Task, type TaskPriority, type ThinkingLevel } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import {
@@ -11,6 +11,8 @@ import {
checkDuplicateTasks,
fetchGitRemotes,
uploadAttachment,
fetchBoardWorkflows,
type BoardWorkflowsPayload,
type CreateTaskInput,
type DuplicateMatch,
type GitHubIssue,
@@ -31,6 +33,7 @@ import { useNodes } from "../hooks/useNodes";
import { useViewportMode } from "../hooks/useViewportMode";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
import { FloatingWindow } from "./FloatingWindow";
import { resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, resolveQuickAddStartWorkflowTarget, validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart";
type NewTaskCreateInput = Omit<CreateTaskInput, "branchSelection"> & {
branchSelection?: {
@@ -46,6 +49,7 @@ interface NewTaskModalProps {
projectId?: string;
tasks: Task[]; // for dependency selection
onCreateTask: (input: NewTaskCreateInput) => Promise<Task>;
onMoveTask?: (taskId: string, column: ColumnId) => Promise<Task>;
addToast: (message: string, type?: ToastType) => void;
initialDescription?: string;
initialWorkflowId?: string | null;
@@ -87,6 +91,14 @@ function buildGitHubPullPrompt(pull: GitHubReferenceOption): string {
return `Fetch and read this GitHub pull request, inspect the conversation, review comments, check failures, and changed files as needed, then resolve or address all actionable PR review comments.\n\nPR: ${pull.url}\n\nKeep the PR intent intact while making the requested fixes, and verify the result with targeted tests.`;
}
function isUsableBoardWorkflowsPayload(value: unknown): value is BoardWorkflowsPayload {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const payload = value as Partial<BoardWorkflowsPayload>;
return payload.flagEnabled === true
&& typeof payload.defaultWorkflowId === "string"
&& Array.isArray(payload.workflows);
}
function defaultGitHubRemote(remotes: GitRemote[]): GitRemote | undefined {
if (remotes.length === 1) return remotes[0];
return remotes.find((remote) => remote.name === "origin");
@@ -323,7 +335,7 @@ function NewTaskGitHubReferencePicker({ isOpen, projectId, disabled = false, onS
);
}
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, initialDescription = "", initialWorkflowId, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, onMoveTask, addToast, initialDescription = "", initialWorkflowId, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const viewportMode = useViewportMode();
@@ -373,11 +385,20 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
// `null` = explicit "No workflow", `string` = a specific workflow. Materialized
// atomically at create time via the `workflowId` create parameter.
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null | undefined>(undefined);
/*
FNXC:NewTaskWorkflowStart 2026-08-19-00:16:
Preserve the tri-state workflow choice across duplicate acknowledgement. The modal locks the
controls while checking duplicates, but this snapshot also prevents a late rerender from
replacing the operator's explicit string, null opt-out, or omitted default intent.
*/
const pendingWorkflowSelectionRef = useRef<string | null | undefined>(undefined);
const pendingStartWorkflowRef = useRef<ValidatedQuickAddWorkflow | null>(null);
// Optional workflow steps the user opted into; TaskForm fetches + seeds these
// from the selected workflow's defaultOn and lifts the enabled set up here.
const [enabledWorkflowSteps, setEnabledWorkflowSteps] = useState<string[]>([]);
const [shouldSubmitEnabledWorkflowSteps, setShouldSubmitEnabledWorkflowSteps] = useState(false);
const [hasUserSelectedEnabledWorkflowSteps, setHasUserSelectedEnabledWorkflowSteps] = useState(false);
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
@@ -459,6 +480,31 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
wasOpenRef.current = isOpen;
}, [initialDescription, initialWorkflowId, isOpen]);
/*
FNXC:NewTaskWorkflowStart 2026-08-19-00:17:
Start eligibility comes from the board endpoint's resolved manualIntake metadata, not the
editable workflow picker catalog. Clear it on every modal open so stale project/workflow data
cannot expose a lifecycle action after a project switch or metadata failure.
*/
useEffect(() => {
if (!isOpen) {
setBoardWorkflows(null);
return;
}
let cancelled = false;
setBoardWorkflows(null);
fetchBoardWorkflows(projectId)
.then((payload) => {
if (!cancelled && isUsableBoardWorkflowsPayload(payload)) setBoardWorkflows(payload);
})
.catch(() => {
if (!cancelled) setBoardWorkflows(null);
});
return () => {
cancelled = true;
};
}, [isOpen, projectId]);
// Load agents for agent picker
const loadAgents = useCallback(() => {
setShowAgentPicker(true);
@@ -520,6 +566,22 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new" || branchMode === "shared-group";
const hasInvalidBranchSelection = isBranchNameRequired && !branch.trim();
const resolvedStartWorkflowId = selectedWorkflowId === null
? null
: selectedWorkflowId ?? boardWorkflows?.defaultWorkflowId;
const startWorkflowCandidate = resolvedStartWorkflowId && boardWorkflows
? boardWorkflows.workflows.find((workflow) => workflow.id === resolvedStartWorkflowId)
: undefined;
const validatedStartWorkflow = validateQuickAddStartWorkflow(startWorkflowCandidate);
const startWorkflowTarget = resolveQuickAddStartWorkflowTarget(validatedStartWorkflow);
const canStartTask = Boolean(
validatedStartWorkflow
&& workflowSupportsQuickAddStart(validatedStartWorkflow)
&& startWorkflowTarget
&& onMoveTask,
);
const canStartTaskNow = canStartTask && Boolean(description.trim()) && !isSubmitting;
const handleExecutorModelChange = useCallback((value: string, meta?: TaskFormValueChangeMeta) => {
setExecutorModel(value);
if (meta?.source === "initialization") {
@@ -631,7 +693,12 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onClose();
}, [onClose, resetForm]);
const performCreate = useCallback(async (trimmedDesc: string, acknowledgedDuplicates?: string[]) => {
const performCreate = useCallback(async (
trimmedDesc: string,
acknowledgedDuplicates: string[] | undefined,
workflowSelection: string | null | undefined,
startWorkflow: ValidatedQuickAddWorkflow | null,
) => {
const executorSlashIdx = executorModel.indexOf("/");
const validatorSlashIdx = validatorModel.indexOf("/");
const planningSlashIdx = planningModel.indexOf("/");
@@ -648,7 +715,17 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
// - undefined → omit (store inherits the project default, today's behavior)
// - null → explicit "No workflow" (store skips default materialization)
// - string → that workflow, materialized atomically at create time.
...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}),
...(startWorkflow
? { workflowId: startWorkflow.id }
: workflowSelection !== undefined
? { workflowId: workflowSelection }
: {}),
...(startWorkflow
? (() => {
const initialColumn = resolveQuickAddStartInitialColumn(startWorkflow);
return initialColumn ? { column: initialColumn as ColumnId } : {};
})()
: {}),
// Optional steps are omitted only when no controls were available. Fast always submits explicit []/ids so async metadata races cannot fall back to store defaultOn gates.
...(shouldSubmitEnabledWorkflowSteps || executionMode === "fast" ? { enabledWorkflowSteps } : {}),
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
@@ -691,6 +768,33 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
// the executor can never observe the task with the wrong step set.
const task = await onCreateTask(createInput);
let startSucceeded = false;
if (startWorkflow) {
const initialColumn = resolveQuickAddStartInitialColumn(startWorkflow);
if (initialColumn) {
// Coding (Ideas) has a proven direct destination in the atomic create request.
startSucceeded = true;
} else if (
onMoveTask
&& typeof task.id === "string"
&& task.id.trim()
&& typeof task.column === "string"
&& task.column.trim()
&& typeof (task as Task & { workflowId?: unknown }).workflowId === "string"
&& (task as Task & { workflowId?: string }).workflowId === startWorkflow.id
) {
const target = resolveQuickAddStartTargetColumn(startWorkflow, task.column);
if (target) {
try {
await onMoveTask(task.id, target as ColumnId);
startSucceeded = true;
} catch {
// The created task remains visible; the final toast reports this partial outcome.
}
}
}
}
// Upload pending images as attachments
if (pendingImages.length > 0) {
const failures: string[] = [];
@@ -707,11 +811,23 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
}
resetForm();
addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success");
if (startWorkflow) {
addToast(
startSucceeded
? t("newTaskModal.taskQueued", "Queued {{taskId}} for planning", { taskId: task.id })
: t("newTaskModal.taskCreatedNotStarted", "Created {{taskId}}, but could not start it", { taskId: task.id }),
startSucceeded ? "success" : "error",
);
} else {
addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success");
}
onClose();
}, [executorModel, credentialInstanceId, validatorModel, validatorCredentialInstanceId, planningModel, planningCredentialInstanceId, thinkingLevel, plannerOversightLevel, dependencies, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, pendingImages, resetForm, addToast, t, onClose, projectId]);
}, [executorModel, credentialInstanceId, validatorModel, validatorCredentialInstanceId, planningModel, planningCredentialInstanceId, thinkingLevel, plannerOversightLevel, dependencies, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, onMoveTask, pendingImages, resetForm, addToast, t, onClose, projectId]);
const handleSubmit = useCallback(async () => {
const handleSubmit = useCallback(async (startWorkflow: ValidatedQuickAddWorkflow | null = null) => {
const workflowSelection = selectedWorkflowId;
pendingWorkflowSelectionRef.current = workflowSelection;
pendingStartWorkflowRef.current = startWorkflow;
const trimmedDesc = description.trim();
if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection) return;
@@ -729,15 +845,22 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
}
try {
await performCreate(trimmedDesc);
await performCreate(trimmedDesc, undefined, workflowSelection, startWorkflow);
} catch (err) {
addToast(getErrorMessage(err) || t("newTaskModal.failedToCreate", "Failed to create task"), "error");
} finally {
if (!keepSubmittingForDuplicateChoice) {
pendingWorkflowSelectionRef.current = undefined;
pendingStartWorkflowRef.current = null;
setIsSubmitting(false);
}
}
}, [description, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, projectId, addToast, t, performCreate]);
}, [description, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, projectId, addToast, t, performCreate, selectedWorkflowId]);
const handleStartSubmit = useCallback(() => {
if (!canStartTaskNow || !validatedStartWorkflow) return;
void handleSubmit(validatedStartWorkflow);
}, [canStartTaskNow, handleSubmit, validatedStartWorkflow]);
const handleDuplicateOpen = useCallback((taskId: string) => {
setDuplicateMatches(null);
@@ -752,6 +875,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const trimmedDesc = description.trim();
const matches = duplicateMatches;
if (!trimmedDesc || !matches || matches.length === 0) {
pendingWorkflowSelectionRef.current = undefined;
pendingStartWorkflowRef.current = null;
setDuplicateMatches(null);
setIsSubmitting(false);
return;
@@ -760,15 +885,23 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setDuplicateMatches(null);
setIsSubmitting(true);
try {
await performCreate(trimmedDesc, matches.map((match) => match.id));
await performCreate(
trimmedDesc,
matches.map((match) => match.id),
pendingWorkflowSelectionRef.current,
pendingStartWorkflowRef.current,
);
} catch (err) {
addToast(getErrorMessage(err) || t("newTaskModal.failedToCreate", "Failed to create task"), "error");
} finally {
pendingWorkflowSelectionRef.current = undefined;
setIsSubmitting(false);
}
}, [description, duplicateMatches, performCreate, addToast, t]);
const handleDuplicateCancel = useCallback(() => {
pendingWorkflowSelectionRef.current = undefined;
pendingStartWorkflowRef.current = null;
setDuplicateMatches(null);
setIsSubmitting(false);
}, []);
@@ -1096,9 +1229,12 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onGithubTrackingEnabledChange={handleGithubTrackingEnabledChange}
githubRepoOverride={githubRepoOverride}
onGithubRepoOverrideChange={setGithubRepoOverride}
onCreateSubmit={handleSubmit}
onCreateSubmit={() => { void handleSubmit(); }}
createSubmitLabel={isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")}
createSubmitDisabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
onStartSubmit={canStartTask && (Boolean(description.trim()) || isSubmitting) ? handleStartSubmit : undefined}
startSubmitLabel={isSubmitting ? t("newTaskModal.starting", "Starting...") : t("newTaskModal.startTask", "Start")}
startSubmitDisabled={!canStartTaskNow}
renderBelowPrimary={quickFields}
hideDependencies={true}
autoExpandMoreOptionsOnSelection={false}

View File

@@ -21,7 +21,7 @@ import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
import { WorkflowIcon } from "./WorkflowIcon";
import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews";
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart";
import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartInitialColumn, resolveQuickAddStartWorkflowTarget, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart";
import { computeFixedMenuPosition, getLayoutViewportSize } from "../utils/fixedMenuPosition";
import { isInsidePortaledModelMenu } from "../utils/portalSurfaces";
import { useQuickAddSubmitOnEnter } from "../hooks/useQuickAddSubmitOnEnter";
@@ -388,6 +388,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const selectedWorkflowForCreate = workflowId === undefined ? undefined : quickEntryWorkflowId;
const validatedStartWorkflow = useMemo(() => validateQuickAddStartWorkflow(selectedQuickEntryWorkflow), [selectedQuickEntryWorkflow]);
const startInitialColumn = validatedStartWorkflow ? resolveQuickAddStartInitialColumn(validatedStartWorkflow) : null;
const startWorkflowTarget = validatedStartWorkflow ? resolveQuickAddStartWorkflowTarget(validatedStartWorkflow) : null;
/*
FNXC:QuickAddStart 2026-07-31-23:51:
Start is a VISIBLE button in the quick-add action row for eligible workflows only, replacing the hidden
@@ -397,7 +398,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
for the follow-up move). Workflows without a waiting lane render no Start button at all — Save stays the single
create affordance there.
*/
const canQuickAddStart = Boolean(validatedStartWorkflow && workflowSupportsQuickAddStart(validatedStartWorkflow) && (startInitialColumn || onMoveTask));
const canQuickAddStart = Boolean(validatedStartWorkflow && workflowSupportsQuickAddStart(validatedStartWorkflow) && startWorkflowTarget && (startInitialColumn || onMoveTask));
const canQuickAddStartNow = canQuickAddStart && Boolean(description.trim()) && !isSubmitting;
useEffect(() => {

View File

@@ -176,10 +176,19 @@ export interface TaskFormProps {
onClose?: () => void;
// Create-mode primary submission. NewTaskModal owns duplicate checks and payload shaping;
// TaskForm only places the visible Create affordance in the quick-action row.
// TaskForm only places the visible Create/Start affordances in the quick-action row.
onCreateSubmit?: () => void;
createSubmitLabel?: string;
createSubmitDisabled?: boolean;
/*
* FNXC:NewTaskWorkflowStart 2026-08-19-00:17:
* Start is supplied only by a host that has validated server-derived manual-intake metadata and
* a safe destination. Keeping this optional prevents an ineligible workflow from leaving an
* empty button shell in either the desktop modal or mobile sheet.
*/
onStartSubmit?: () => void;
startSubmitLabel?: string;
startSubmitDisabled?: boolean;
/** Optional content to render between the primary section and the "More options" toggle. */
renderBelowPrimary?: React.ReactNode;
@@ -259,6 +268,9 @@ export function TaskForm({
onCreateSubmit,
createSubmitLabel,
createSubmitDisabled,
onStartSubmit,
startSubmitLabel,
startSubmitDisabled,
renderBelowPrimary,
renderBelowModelConfiguration,
hideDependencies,
@@ -1032,6 +1044,20 @@ export function TaskForm({
{createSubmitLabel ?? t("taskForm.createTask", "Create")}
</button>
)}
{onStartSubmit && (
<button
type="button"
className="btn btn-sm"
onClick={onStartSubmit}
disabled={disabled || startSubmitDisabled}
data-testid="task-form-inline-start"
aria-label={startSubmitLabel ?? t("taskForm.startTask", "Start")}
title={startSubmitLabel ?? t("taskForm.startTask", "Start")}
>
<Zap size={12} className="task-form-action-icon" aria-hidden="true" />
{startSubmitLabel ?? t("taskForm.startTask", "Start")}
</button>
)}
{onPlanningMode && (
<button
type="button"

View File

@@ -114,8 +114,12 @@ vi.mock("../ScheduledTasksModal", () => ({
},
}));
const mockNewTaskModalProps = vi.fn();
vi.mock("../NewTaskModal", () => ({
NewTaskModal: () => null,
NewTaskModal: (props: any) => {
mockNewTaskModalProps(props);
return null;
},
}));
const mockActivityLogModalProps = vi.fn();
@@ -373,9 +377,36 @@ describe("AppModals", () => {
mockModelOnboardingModalProps.mockClear();
mockActivityLogModalProps.mockClear();
mockSettingsModalProps.mockClear();
mockNewTaskModalProps.mockClear();
restoreDesktopViewport();
});
it("bridges the production move handler into NewTaskModal Start", async () => {
const moveTask = vi.fn();
render(
<AppModals
projectId="project-a"
tasks={[]}
projects={[]}
currentProject={null}
addToast={vi.fn()}
toasts={mockToasts}
removeToast={vi.fn()}
modalManager={{ ...mockModalManager, newTaskModalOpen: true }}
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
taskOperations={{ moveTask, deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
deepLink={{ handleDetailClose: vi.fn() }}
settings={mockSettings}
/>,
);
const modalProps = mockNewTaskModalProps.mock.calls[0]?.[0];
expect(modalProps?.onMoveTask).toEqual(expect.any(Function));
await modalProps.onMoveTask("FN-START", "building");
expect(moveTask).toHaveBeenCalledWith("FN-START", "building");
});
it("renders without crashing", () => {
render(
<AppModals

View File

@@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
import { readFileSync } from "node:fs";
import { NewTaskModal } from "../NewTaskModal";
import type { Task, Column } from "@fusion/core";
import { apiFetchGitHubIssues, apiFetchGitHubPulls, checkDuplicateTasks, fetchAgents, fetchGitRemotes, type BoardWorkflowsPayload } from "../../api";
import { apiFetchGitHubIssues, apiFetchGitHubPulls, checkDuplicateTasks, fetchAgents, fetchBoardWorkflows, fetchGitRemotes, type BoardWorkflowsPayload } from "../../api";
import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow";
import { GITHUB_SETUP_WARNING_DELAY_MS, GITHUB_SETUP_WARNING_MISSING_SINCE_KEY } from "../../hooks/useGithubSetupWarningDelay";
@@ -45,6 +45,7 @@ vi.mock("../../api", () => ({
uploadAttachment: vi.fn().mockResolvedValue({}),
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
fetchGitRemotes: vi.fn().mockResolvedValue([]),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: true, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }),
apiFetchGitHubIssues: vi.fn().mockResolvedValue([]),
apiFetchGitHubPulls: vi.fn().mockResolvedValue([]),
fetchModels: vi.fn().mockResolvedValue({ models: [
@@ -144,6 +145,7 @@ describe("NewTaskModal", () => {
mockConfirm.mockResolvedValue(true);
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
vi.mocked(fetchGitRemotes).mockResolvedValue([]);
vi.mocked(fetchBoardWorkflows).mockResolvedValue({ flagEnabled: true, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} });
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]);
window.localStorage.clear();
@@ -1734,6 +1736,25 @@ describe("NewTaskModal", () => {
});
});
it("preserves the selected workflow through duplicate acknowledgement", async () => {
await mockWorkflows([{ id: "WF-DUP", name: "Duplicate-safe" }]);
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-DUP", title: "Existing", description: "Existing duplicate", column: "todo", score: 0.9 },
]);
const { props } = renderNewTaskModal();
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
await chooseWorkflowOption("WF-DUP");
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Duplicate-safe task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await screen.findByText("Possible duplicates");
fireEvent.click(screen.getByRole("button", { name: "Create anyway" }));
await waitFor(() => expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: "WF-DUP", acknowledgedDuplicates: ["FN-DUP"] }),
));
});
it("does not render the legacy per-step checkbox UI", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
renderNewTaskModal();
@@ -1746,6 +1767,175 @@ describe("NewTaskModal", () => {
});
});
describe("manual-intake Start", () => {
function mockStartWorkflows(id: string, name: string) {
return import("../../api").then(({ fetchWorkflows }) => {
vi.mocked(fetchWorkflows).mockResolvedValueOnce([{
id,
name,
description: "",
kind: "workflow",
ir: { version: "v1", name, nodes: [], edges: [] },
layout: {},
createdAt: "",
updatedAt: "",
}] as any);
});
}
const codingIdeasBoardPayload: BoardWorkflowsPayload = {
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [{
id: "builtin:coding-ideas",
name: "Coding (Ideas)",
columns: [
{ id: "ideas", name: "Ideas", flags: { intake: true, hold: true, manualIntake: true } },
{ id: "todo", name: "Todo", flags: {} },
{ id: "done", name: "Done", flags: { complete: true } },
],
}],
taskWorkflowIds: {},
};
it("atomically creates Coding (Ideas) Start in its proven working column", async () => {
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce(codingIdeasBoardPayload);
const onCreateTask = vi.fn().mockResolvedValue({ ...makeTask("FN-START"), column: "todo", workflowId: "builtin:coding-ideas" });
const onMoveTask = vi.fn();
const { props } = renderNewTaskModal({ onCreateTask, onMoveTask });
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
await chooseWorkflowOption("builtin:coding-ideas");
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Start this idea" } });
const start = await screen.findByTestId("task-form-inline-start");
expect(start).toBeEnabled();
fireEvent.click(start);
await waitFor(() => expect(onCreateTask).toHaveBeenCalledWith(expect.objectContaining({
workflowId: "builtin:coding-ideas",
column: "todo",
description: "Start this idea",
})));
expect(onMoveTask).not.toHaveBeenCalled();
expect(props.addToast).toHaveBeenCalledWith("Queued FN-START for planning", "success");
});
it("exposes the same eligible Start action in the desktop floating host", async () => {
mockViewportMode = "desktop";
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce(codingIdeasBoardPayload);
renderNewTaskModal({ onMoveTask: vi.fn() });
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
await chooseWorkflowOption("builtin:coding-ideas");
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Desktop start" } });
expect(await screen.findByTestId("task-form-inline-start")).toBeVisible();
expect(screen.getByTestId("task-form-description-actions")).toContainElement(screen.getByTestId("task-form-inline-start"));
});
it("creates a custom manual-intake task before moving it once to the validated target", async () => {
await mockStartWorkflows("WF-MANUAL", "Manual intake");
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [{
id: "WF-MANUAL",
name: "Manual intake",
columns: [
{ id: "waiting", name: "Waiting", flags: { intake: true, manualIntake: true } },
{ id: "building", name: "Building", flags: {} },
{ id: "done", name: "Done", flags: { complete: true } },
],
}],
taskWorkflowIds: {},
});
const onMoveTask = vi.fn().mockResolvedValue({ ...makeTask("FN-CUSTOM"), column: "building", workflowId: "WF-MANUAL" });
const onCreateTask = vi.fn().mockResolvedValue({ ...makeTask("FN-CUSTOM"), column: "waiting", workflowId: "WF-MANUAL" });
const { props } = renderNewTaskModal({ onCreateTask, onMoveTask });
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
await chooseWorkflowOption("WF-MANUAL");
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Start custom intake" } });
fireEvent.click(await screen.findByTestId("task-form-inline-start"));
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-CUSTOM", "building"));
expect(onCreateTask).toHaveBeenCalledWith(expect.objectContaining({ workflowId: "WF-MANUAL" }));
expect(onMoveTask).toHaveBeenCalledTimes(1);
expect(onCreateTask.mock.invocationCallOrder[0]).toBeLessThan(onMoveTask.mock.invocationCallOrder[0]);
expect(props.addToast).toHaveBeenCalledWith("Queued FN-CUSTOM for planning", "success");
});
it("reports a partial outcome when custom Start move fails after creation", async () => {
await mockStartWorkflows("WF-MANUAL", "Manual intake");
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [{
id: "WF-MANUAL",
name: "Manual intake",
columns: [
{ id: "waiting", name: "Waiting", flags: { intake: true, manualIntake: true } },
{ id: "building", name: "Building", flags: {} },
],
}],
taskWorkflowIds: {},
});
const onCreateTask = vi.fn().mockResolvedValue({ ...makeTask("FN-PARTIAL"), column: "waiting", workflowId: "WF-MANUAL" });
const onMoveTask = vi.fn().mockRejectedValue(new Error("move unavailable"));
const { props } = renderNewTaskModal({ onCreateTask, onMoveTask });
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
await chooseWorkflowOption("WF-MANUAL");
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Keep created task" } });
fireEvent.click(await screen.findByTestId("task-form-inline-start"));
await waitFor(() => expect(props.addToast).toHaveBeenCalledWith("Created FN-PARTIAL, but could not start it", "error"));
expect(onCreateTask).toHaveBeenCalledTimes(1);
expect(onMoveTask).toHaveBeenCalledTimes(1);
});
it("does not leave a Start shell for an empty description", async () => {
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce(codingIdeasBoardPayload);
renderNewTaskModal({ onMoveTask: vi.fn() });
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
await chooseWorkflowOption("builtin:coding-ideas");
expect(screen.queryByTestId("task-form-inline-start")).toBeNull();
});
it("hides Start when No workflow is selected", async () => {
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce(codingIdeasBoardPayload);
renderNewTaskModal({ onMoveTask: vi.fn() });
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
await chooseWorkflowOption("__none__");
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "No workflow Start" } });
expect(screen.queryByTestId("task-form-inline-start")).toBeNull();
});
it("hides Start for auto-triage and malformed workflow metadata", async () => {
await mockStartWorkflows("builtin:coding", "Coding");
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [{
id: "builtin:coding",
name: "Coding",
columns: [{ id: "planning", name: "Planning", flags: { intake: true, manualIntake: false } }, { id: "done", name: "Done", flags: { complete: true } }],
}],
taskWorkflowIds: {},
});
const { onMoveTask } = renderNewTaskModal({ onMoveTask: vi.fn() }).props;
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
expect(screen.queryByTestId("task-form-inline-start")).toBeNull();
expect(onMoveTask).not.toHaveBeenCalled();
});
});
// Review level tests (FN-2241)
describe("review level selection payload", () => {
it("omits reviewLevel from payload when not selected", async () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, validateQuickAddStartWorkflow, workflowSupportsQuickAddStart } from "../quickAddStart";
import { resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, resolveQuickAddStartWorkflowTarget, validateQuickAddStartWorkflow, workflowSupportsQuickAddStart } from "../quickAddStart";
const workflow = (overrides: Record<string, unknown> = {}) => ({
id: "custom",
@@ -78,6 +78,20 @@ describe("quick add Start workflow guards", () => {
}))).toBeNull();
});
it("proves a Start target from the manual intake lane", () => {
const valid = validateQuickAddStartWorkflow(workflow({ columns: [
{ id: "waiting", name: "Waiting", flags: { intake: true, manualIntake: true } },
{ id: "building", name: "Building", flags: {} },
{ id: "done", name: "Done", flags: { complete: true } },
] }));
expect(resolveQuickAddStartWorkflowTarget(valid)).toBe("building");
const noTarget = validateQuickAddStartWorkflow(workflow({ columns: [
{ id: "waiting", name: "Waiting", flags: { intake: true, manualIntake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
] }));
expect(resolveQuickAddStartWorkflowTarget(noTarget)).toBeNull();
});
it("only chooses a later visible working destination", () => {
const valid = validateQuickAddStartWorkflow(workflow({ columns: [
{ id: "ideas", name: "Ideas", flags: { hold: true } },

View File

@@ -64,6 +64,21 @@ export function resolveQuickAddStartTargetColumn(workflow: ValidatedQuickAddWork
* precede one non-intake, non-complete Todo lane. Missing, hidden, reordered, or malformed
* metadata fails closed rather than guessing a transition.
*/
/*
FNXC:NewTaskWorkflowStart 2026-08-19-00:17:
The modal and QuickEntryBox must hide Start when the metadata proves manual intake but no later
working lane exists. Resolve that proof from the same ordered workflow snapshot used for the actual
create or move, retaining the Coding (Ideas) atomic-column special case.
*/
export function resolveQuickAddStartWorkflowTarget(workflow: ValidatedQuickAddWorkflow | null): string | null {
if (!workflow || !workflowSupportsQuickAddStart(workflow)) return null;
const initialColumn = resolveQuickAddStartInitialColumn(workflow);
if (initialColumn) return initialColumn;
if (workflow.id === "builtin:coding-ideas") return null;
const intakeColumn = visibleColumns(workflow)[0]?.id;
return intakeColumn ? resolveQuickAddStartTargetColumn(workflow, intakeColumn) : null;
}
export function resolveQuickAddStartInitialColumn(workflow: ValidatedQuickAddWorkflow): string | null {
if (workflow.id !== "builtin:coding-ideas") return null;
const columns = visibleColumns(workflow);