FN-6804: carry workflow selection into planning creates
Planning and subtask creation now preserve the workflow lane chosen from dashboard task entry points. - Thread optional workflow IDs through quick entry, modal state, planning mode, and subtask breakdown flows. - Apply supplied or parent-derived workflow IDs when creating planning and breakdown tasks, including validation errors for invalid workflow selections. - Cover workflow-aware task creation and quick-entry modal handoff with regression tests and document the dashboard behavior. Files changed: .changeset/planning-subtask-workflow-selection.md | 5 + docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 8 +- packages/dashboard/app/api/legacy.ts | 6 + packages/dashboard/app/components/AppModals.tsx | 2 + packages/dashboard/app/components/Board.tsx | 4 +- packages/dashboard/app/components/Column.tsx | 5 +- .../dashboard/app/components/InlineCreateCard.tsx | 20 ++- packages/dashboard/app/components/Lane.tsx | 4 +- packages/dashboard/app/components/ListView.tsx | 4 +- .../dashboard/app/components/PlanningModeModal.tsx | 18 +- .../dashboard/app/components/QuickEntryBox.tsx | 24 ++- .../app/components/SubtaskBreakdownModal.tsx | 20 ++- packages/dashboard/app/components/TaskForm.tsx | 4 +- .../components/__tests__/QuickEntryBox.test.tsx | 34 ++++ packages/dashboard/app/hooks/useModalManager.ts | 26 ++- .../planning-subtask-create-workflow-route.test.ts | 192 +++++++++++++++++++++ .../src/routes/register-planning-subtask-routes.ts | 72 ++++++-- 18 files changed, 398 insertions(+), 52 deletions(-) Fusion-Task-Id: FN-6804 Fusion-Task-Lineage: ca03e666-2dd4-4983-8c84-c5540f5adb0b
This commit is contained in:
5
.changeset/planning-subtask-workflow-selection.md
Normal file
5
.changeset/planning-subtask-workflow-selection.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Carry the selected workflow lane through Planning Mode and Subtask Breakdown task creation so saved tasks appear on the active workflow instead of falling back to the main board.
|
||||
@@ -224,6 +224,8 @@ Planning Mode now includes branch controls on the summary screen before you crea
|
||||
|
||||
These values are sent with the Planning Mode create-task request as `branchSelection`, so created tasks persist branch/base-branch settings consistently with other branch-aware task creation flows.
|
||||
|
||||
When Planning Mode or Subtask Breakdown is opened from a workflow-filtered board lane, the create request also carries that active workflow selection. Single-task planning saves, planning breakdown saves, and subtask-breakdown saves create their tasks directly on the selected workflow lane instead of briefly landing on the default board.
|
||||
|
||||
Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer. History rows are deduplicated by session id even if the initial load and live session updates arrive out of order, and deleting a history entry now waits for the server delete to persist (failures keep the row visible and surface an error instead of silently disappearing until refresh).
|
||||
|
||||
## New Task Modal Branch Strategy
|
||||
|
||||
@@ -1245,8 +1245,8 @@ function AppInner() {
|
||||
pushNav({ type: "modal", close: modalManager.closePlanning });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string) => {
|
||||
modalManager.openPlanningWithInitialPlan(initialPlan);
|
||||
const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string, workflowId?: string | null) => {
|
||||
modalManager.openPlanningWithInitialPlan(initialPlan, workflowId);
|
||||
pushNav({ type: "modal", close: modalManager.closePlanning });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
@@ -1255,8 +1255,8 @@ function AppInner() {
|
||||
pushNav({ type: "modal", close: modalManager.closePlanning });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openSubtaskBreakdownWithNav = useCallback((description: string) => {
|
||||
modalManager.openSubtaskBreakdown(description);
|
||||
const openSubtaskBreakdownWithNav = useCallback((description: string, workflowId?: string | null) => {
|
||||
modalManager.openSubtaskBreakdown(description, workflowId);
|
||||
pushNav({ type: "modal", close: modalManager.closeSubtask });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
|
||||
@@ -3645,6 +3645,7 @@ export function createTaskFromPlanning(
|
||||
branchName?: string;
|
||||
baseBranch?: string;
|
||||
};
|
||||
workflowId?: string | null;
|
||||
},
|
||||
): Promise<Task> {
|
||||
return api<Task>(withProjectId("/planning/create-task", projectId), {
|
||||
@@ -3654,6 +3655,7 @@ export function createTaskFromPlanning(
|
||||
...(options?.branch !== undefined ? { branch: options.branch } : {}),
|
||||
...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}),
|
||||
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
|
||||
...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -3687,6 +3689,7 @@ export function createTasksFromPlanning(
|
||||
branchAssignment?: {
|
||||
mode: "shared" | "per-task-derived";
|
||||
};
|
||||
workflowId?: string | null;
|
||||
},
|
||||
): Promise<{ tasks: Task[] }> {
|
||||
return api<{ tasks: Task[] }>(withProjectId("/planning/create-tasks", projectId), {
|
||||
@@ -3696,6 +3699,7 @@ export function createTasksFromPlanning(
|
||||
subtasks,
|
||||
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
|
||||
...(options?.branchAssignment ? { branchAssignment: options.branchAssignment } : {}),
|
||||
...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -5670,6 +5674,7 @@ export function createTasksFromBreakdown(
|
||||
baseBranch?: string;
|
||||
};
|
||||
branchAssignment?: { mode: "shared" | "per-task-derived" };
|
||||
workflowId?: string | null;
|
||||
},
|
||||
): Promise<{ tasks: Task[]; parentTaskClosed?: boolean }> {
|
||||
return api<{ tasks: Task[]; parentTaskClosed?: boolean }>(withProjectId("/subtasks/create-tasks", projectId), {
|
||||
@@ -5681,6 +5686,7 @@ export function createTasksFromBreakdown(
|
||||
...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}),
|
||||
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
|
||||
...(options?.branchAssignment ? { branchAssignment: options.branchAssignment } : {}),
|
||||
...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}),
|
||||
subtasks: subtasks.map((subtask) => ({
|
||||
tempId: subtask.id,
|
||||
title: subtask.title,
|
||||
|
||||
@@ -359,6 +359,7 @@ export function AppModals({
|
||||
tasks={tasks}
|
||||
initialPlan={modalManager.planningInitialPlan ?? undefined}
|
||||
projectId={projectId}
|
||||
workflowId={modalManager.planningWorkflowId}
|
||||
resumeSessionId={modalManager.planningResumeSessionId}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
@@ -370,6 +371,7 @@ export function AppModals({
|
||||
initialDescription={modalManager.subtaskInitialDescription ?? ""}
|
||||
onTasksCreated={taskHandlers.handleSubtaskTasksCreated}
|
||||
projectId={projectId}
|
||||
workflowId={modalManager.subtaskWorkflowId}
|
||||
resumeSessionId={modalManager.subtaskResumeSessionId}
|
||||
onOpenGroupModal={openGroupModalWithNav}
|
||||
/>
|
||||
|
||||
@@ -52,11 +52,11 @@ interface BoardProps {
|
||||
/**
|
||||
* Called when the user clicks the "Plan" button in the inline create card.
|
||||
*/
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
/**
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void;
|
||||
favoriteProviders?: string[];
|
||||
favoriteModels?: string[];
|
||||
|
||||
@@ -121,11 +121,11 @@ interface ColumnProps {
|
||||
/**
|
||||
* Called when the user clicks the "Plan" button in the inline create card.
|
||||
*/
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
/**
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void;
|
||||
favoriteProviders?: string[];
|
||||
favoriteModels?: string[];
|
||||
@@ -689,6 +689,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
availableModels={availableModels}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
workflowId={workflowMode ? workflowId : undefined}
|
||||
projectId={projectId}
|
||||
autoExpand={false}
|
||||
favoriteProviders={favoriteProviders}
|
||||
|
||||
@@ -39,11 +39,11 @@ interface InlineCreateCardProps {
|
||||
/**
|
||||
* Called when the user clicks the "Plan" button to open planning mode.
|
||||
*/
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
/**
|
||||
* Called when the user clicks the "Subtask" button to trigger subtask breakdown.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
}
|
||||
|
||||
function getNodeStatusLabel(status: NodeInfo["status"], t?: (key: string, defaultValue: string) => string): string {
|
||||
@@ -693,7 +693,11 @@ export function InlineCreateCard({
|
||||
addToast(t("inline.enterDescriptionFirst", "Enter a description first"), "error");
|
||||
return;
|
||||
}
|
||||
onPlanningMode?.(trimmed);
|
||||
if (selectedWorkflowId !== null) {
|
||||
onPlanningMode?.(trimmed, selectedWorkflowId);
|
||||
} else {
|
||||
onPlanningMode?.(trimmed);
|
||||
}
|
||||
// Clear the input after triggering planning mode
|
||||
setDescription("");
|
||||
setSelectedWorkflowId(null);
|
||||
@@ -713,7 +717,7 @@ export function InlineCreateCard({
|
||||
setIsModelModalOpen(false);
|
||||
setShowPresets(false);
|
||||
setIsExpanded(false);
|
||||
}, [description, onPlanningMode, addToast]);
|
||||
}, [description, onPlanningMode, selectedWorkflowId, addToast]);
|
||||
|
||||
const handleSubtaskClick = useCallback(() => {
|
||||
const trimmed = description.trim();
|
||||
@@ -721,7 +725,11 @@ export function InlineCreateCard({
|
||||
addToast(t("inline.enterDescriptionFirst", "Enter a description first"), "error");
|
||||
return;
|
||||
}
|
||||
onSubtaskBreakdown?.(trimmed);
|
||||
if (selectedWorkflowId !== null) {
|
||||
onSubtaskBreakdown?.(trimmed, selectedWorkflowId);
|
||||
} else {
|
||||
onSubtaskBreakdown?.(trimmed);
|
||||
}
|
||||
// Clear the input after triggering subtask breakdown
|
||||
setDescription("");
|
||||
setSelectedWorkflowId(null);
|
||||
@@ -741,7 +749,7 @@ export function InlineCreateCard({
|
||||
setIsModelModalOpen(false);
|
||||
setShowPresets(false);
|
||||
setIsExpanded(false);
|
||||
}, [description, onSubtaskBreakdown, addToast]);
|
||||
}, [description, onSubtaskBreakdown, selectedWorkflowId, addToast]);
|
||||
|
||||
const truncate = (s: string, len: number) =>
|
||||
s.length > len ? s.slice(0, len) + "…" : s;
|
||||
|
||||
@@ -55,8 +55,8 @@ export interface LaneProps {
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => Promise<Task>;
|
||||
availableModels?: ModelInfo[];
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void;
|
||||
favoriteProviders?: string[];
|
||||
favoriteModels?: string[];
|
||||
|
||||
@@ -219,11 +219,11 @@ interface ListViewProps {
|
||||
/**
|
||||
* Called when the user clicks the "Plan" button in the quick entry box.
|
||||
*/
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
/**
|
||||
* Called when the user clicks the "Subtask" button in the quick entry box.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
/**
|
||||
* Called when tasks are updated (e.g., after bulk model update).
|
||||
* Allows parent to refresh task list or handle optimistically.
|
||||
|
||||
@@ -67,6 +67,8 @@ interface PlanningModeModalProps {
|
||||
tasks: Task[];
|
||||
initialPlan?: string;
|
||||
projectId?: string;
|
||||
/** Active workflow lane selected when Planning Mode was opened. */
|
||||
workflowId?: string | null;
|
||||
/** When set, reconnect to a persisted background session instead of starting fresh */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
@@ -191,7 +193,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, resumeSessionId }: PlanningModeModalProps) {
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId }: PlanningModeModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [initialPlan, setInitialPlan] = useState("");
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
@@ -1631,6 +1633,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
...(branchMode === "existing" || branchMode === "custom-new" ? { branchName: branchName.trim() } : {}),
|
||||
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
|
||||
},
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Planning Mode saves must carry the workflow lane that opened the modal so created tasks do not land on the main board before appearing on the selected sub-board.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId } : {}),
|
||||
});
|
||||
onTaskCreated(task);
|
||||
// Single-task creation should preserve completed planning history, so
|
||||
@@ -1648,7 +1655,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
} finally {
|
||||
setIsCreatingTask(false);
|
||||
}
|
||||
}, [baseBranch, branchMode, branchName, broadcastCompleted, editedSummary, view, projectId, onTaskCreated, handleClose]);
|
||||
}, [baseBranch, branchMode, branchName, broadcastCompleted, editedSummary, view, projectId, workflowId, onTaskCreated, handleClose]);
|
||||
|
||||
const handleStartBreakdown = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
@@ -1702,6 +1709,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
...(branchMode === "existing" || branchMode === "custom-new" ? { branchName: branchName.trim() } : {}),
|
||||
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
|
||||
},
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Planning breakdown saves create several tasks, and every child must inherit the modal's workflow lane selection.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId } : {}),
|
||||
},
|
||||
);
|
||||
onTasksCreated(result.tasks);
|
||||
@@ -1734,7 +1746,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
} finally {
|
||||
setIsCreatingFromBreakdown(false);
|
||||
}
|
||||
}, [baseBranch, branchMode, branchName, broadcastCompleted, handleClose, view, onTasksCreated, projectId]);
|
||||
}, [baseBranch, branchMode, branchName, broadcastCompleted, handleClose, view, onTasksCreated, projectId, workflowId]);
|
||||
|
||||
const handleBack = useCallback(async () => {
|
||||
if (view.type !== "question" || responseHistory.length === 0) {
|
||||
|
||||
@@ -32,11 +32,13 @@ interface QuickEntryBoxProps {
|
||||
/**
|
||||
* Called when the user clicks the "Plan" button to open planning mode.
|
||||
*/
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
/**
|
||||
* Called when the user clicks the "Subtask" button to trigger subtask breakdown.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
/** Selected workflow lane for AI-assisted create actions. Omit in legacy board mode to preserve project-default inheritance. */
|
||||
workflowId?: string | null;
|
||||
/** Optional project context for API calls */
|
||||
projectId?: string;
|
||||
/**
|
||||
@@ -88,7 +90,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, projectId, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, workflowId, projectId, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [description, setDescription] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -1343,10 +1345,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error");
|
||||
return;
|
||||
}
|
||||
onPlanningMode?.(trimmed);
|
||||
if (workflowId !== undefined) {
|
||||
onPlanningMode?.(trimmed, workflowId);
|
||||
} else {
|
||||
onPlanningMode?.(trimmed);
|
||||
}
|
||||
// Clear the form after triggering planning mode
|
||||
resetForm();
|
||||
}, [description, onPlanningMode, addToast, resetForm]);
|
||||
}, [description, onPlanningMode, workflowId, addToast, resetForm]);
|
||||
|
||||
const handleSubtaskClick = useCallback(() => {
|
||||
const trimmed = description.trim();
|
||||
@@ -1354,10 +1360,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error");
|
||||
return;
|
||||
}
|
||||
onSubtaskBreakdown?.(trimmed);
|
||||
if (workflowId !== undefined) {
|
||||
onSubtaskBreakdown?.(trimmed, workflowId);
|
||||
} else {
|
||||
onSubtaskBreakdown?.(trimmed);
|
||||
}
|
||||
// Clear the form after triggering subtask breakdown
|
||||
resetForm();
|
||||
}, [description, onSubtaskBreakdown, addToast, resetForm]);
|
||||
}, [description, onSubtaskBreakdown, workflowId, addToast, resetForm]);
|
||||
|
||||
const handleSaveClick = useCallback(() => {
|
||||
// Save button now creates the task (same as Enter key)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
connectSubtaskStream,
|
||||
createTasksFromBreakdown,
|
||||
fetchAiSession,
|
||||
fetchTaskWorkflow,
|
||||
parseConversationHistory,
|
||||
type SubtaskItem,
|
||||
type ConversationHistoryEntry,
|
||||
@@ -36,6 +37,8 @@ interface SubtaskBreakdownModalProps {
|
||||
onTasksCreated: (tasks: Task[]) => void;
|
||||
parentTaskId?: string;
|
||||
projectId?: string;
|
||||
/** Active workflow lane selected when the breakdown modal was opened. */
|
||||
workflowId?: string | null;
|
||||
resumeSessionId?: string;
|
||||
onOpenGroupModal?: (groupId: string) => void;
|
||||
}
|
||||
@@ -77,7 +80,7 @@ function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
|
||||
return subtasks.some((item) => visit(item.id));
|
||||
}
|
||||
|
||||
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, resumeSessionId, onOpenGroupModal }: SubtaskBreakdownModalProps) {
|
||||
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, workflowId, resumeSessionId, onOpenGroupModal }: SubtaskBreakdownModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const viewportMode = useViewportMode();
|
||||
useMobileScrollLock(isOpen);
|
||||
@@ -545,6 +548,14 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
setError(null);
|
||||
setView({ type: "creating", sessionId });
|
||||
try {
|
||||
let effectiveWorkflowId = workflowId;
|
||||
if (parentTaskId) {
|
||||
try {
|
||||
effectiveWorkflowId = (await fetchTaskWorkflow(parentTaskId, projectId)).workflowId;
|
||||
} catch {
|
||||
effectiveWorkflowId = workflowId;
|
||||
}
|
||||
}
|
||||
const result = await createTasksFromBreakdown(sessionId, subtasks, parentTaskId, projectId, {
|
||||
branchSelection: {
|
||||
mode: branchMode,
|
||||
@@ -552,6 +563,11 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
|
||||
},
|
||||
branchAssignment: { mode: branchAssignmentMode },
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Subtask Breakdown children inherit the parent task's workflow when available, otherwise they keep the workflow lane that opened this modal.
|
||||
*/
|
||||
...(effectiveWorkflowId !== undefined ? { workflowId: effectiveWorkflowId } : {}),
|
||||
});
|
||||
onTasksCreated(result.tasks);
|
||||
resetState();
|
||||
@@ -560,7 +576,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
setError(getErrorMessage(err) || t("subtasks.errorCreateTasks", "Failed to create tasks"));
|
||||
setView({ type: "editing", sessionId });
|
||||
}
|
||||
}, [baseBranch, branchAssignmentMode, branchMode, branchName, isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks]);
|
||||
}, [baseBranch, branchAssignmentMode, branchMode, branchName, isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks, workflowId]);
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
if (view.type !== "error") {
|
||||
|
||||
@@ -127,8 +127,8 @@ export interface TaskFormProps {
|
||||
onGithubRepoOverrideChange?: (value: string) => void;
|
||||
|
||||
// AI-assisted creation callbacks (create mode only)
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
onClose?: () => void;
|
||||
|
||||
/** Optional content to render between the primary section and the "More options" toggle. */
|
||||
|
||||
@@ -2332,6 +2332,40 @@ describe("QuickEntryBox", () => {
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "Plan", buttonId: "plan-button", callbackProp: "onPlanningMode" as const },
|
||||
{ label: "Subtask", buttonId: "subtask-button", callbackProp: "onSubtaskBreakdown" as const },
|
||||
])("passes selected workflow id through %s quick-entry handoff", async ({ buttonId, callbackProp }) => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderQuickEntryBox({ onPlanningMode, onSubtaskBreakdown, workflowId: "WF-123" });
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Create in custom workflow" } });
|
||||
fireEvent.click(screen.getByTestId(buttonId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(callbackProp === "onPlanningMode" ? onPlanningMode : onSubtaskBreakdown)
|
||||
.toHaveBeenCalledWith("Create in custom workflow", "WF-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits workflow id in legacy quick-entry handoff", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
renderQuickEntryBox({ onPlanningMode });
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Create with default workflow" } });
|
||||
fireEvent.click(screen.getByTestId("plan-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPlanningMode).toHaveBeenCalledWith("Create with default workflow");
|
||||
});
|
||||
expect(onPlanningMode.mock.calls[0]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("disables Plan and Subtask buttons when description is empty", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
@@ -32,9 +32,11 @@ export interface ModalManager {
|
||||
isPlanningOpen: boolean;
|
||||
planningInitialPlan: string | null;
|
||||
planningResumeSessionId: string | undefined;
|
||||
planningWorkflowId: string | null | undefined;
|
||||
isSubtaskOpen: boolean;
|
||||
subtaskInitialDescription: string | null;
|
||||
subtaskResumeSessionId: string | undefined;
|
||||
subtaskWorkflowId: string | null | undefined;
|
||||
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
|
||||
detailTask: (Task | TaskDetail) | null;
|
||||
detailTaskInitialTab: DetailTaskTab;
|
||||
@@ -74,12 +76,12 @@ export interface ModalManager {
|
||||
closeNewTask: () => void;
|
||||
|
||||
openPlanning: () => void;
|
||||
openPlanningWithInitialPlan: (initialPlan: string) => void;
|
||||
openPlanningWithInitialPlan: (initialPlan: string, workflowId?: string | null) => void;
|
||||
resumePlanning: () => void;
|
||||
openPlanningWithSession: (sessionId: string) => void;
|
||||
closePlanning: () => void;
|
||||
|
||||
openSubtaskBreakdown: (description: string) => void;
|
||||
openSubtaskBreakdown: (description: string, workflowId?: string | null) => void;
|
||||
openSubtaskWithSession: (sessionId: string) => void;
|
||||
closeSubtask: () => void;
|
||||
|
||||
@@ -158,9 +160,11 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
|
||||
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
|
||||
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [planningWorkflowId, setPlanningWorkflowId] = useState<string | null | undefined>(undefined);
|
||||
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
|
||||
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
|
||||
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [subtaskWorkflowId, setSubtaskWorkflowId] = useState<string | null | undefined>(undefined);
|
||||
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
|
||||
const [detailTask, setDetailTask] = useState<(Task | TaskDetail) | null>(null);
|
||||
/**
|
||||
@@ -229,18 +233,24 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
setNewTaskInitialDescription(null);
|
||||
}, []);
|
||||
|
||||
const openPlanning = useCallback(() => setIsPlanningOpen(true), []);
|
||||
const openPlanningWithInitialPlan = useCallback((initialPlan: string) => {
|
||||
const openPlanning = useCallback(() => {
|
||||
setPlanningWorkflowId(undefined);
|
||||
setIsPlanningOpen(true);
|
||||
}, []);
|
||||
const openPlanningWithInitialPlan = useCallback((initialPlan: string, workflowId?: string | null) => {
|
||||
setPlanningInitialPlan(initialPlan);
|
||||
setPlanningWorkflowId(workflowId);
|
||||
setIsPlanningOpen(true);
|
||||
}, []);
|
||||
const resumePlanning = useCallback(() => {
|
||||
const session = planningSessions[0];
|
||||
if (!session) return;
|
||||
setPlanningWorkflowId(undefined);
|
||||
setPlanningResumeSessionId(session.id);
|
||||
setIsPlanningOpen(true);
|
||||
}, [planningSessions]);
|
||||
const openPlanningWithSession = useCallback((sessionId: string) => {
|
||||
setPlanningWorkflowId(undefined);
|
||||
setPlanningResumeSessionId(sessionId);
|
||||
setIsPlanningOpen(true);
|
||||
}, []);
|
||||
@@ -248,13 +258,16 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
setPlanningResumeSessionId(undefined);
|
||||
setPlanningWorkflowId(undefined);
|
||||
}, []);
|
||||
|
||||
const openSubtaskBreakdown = useCallback((description: string) => {
|
||||
const openSubtaskBreakdown = useCallback((description: string, workflowId?: string | null) => {
|
||||
setSubtaskInitialDescription(description);
|
||||
setSubtaskWorkflowId(workflowId);
|
||||
setIsSubtaskOpen(true);
|
||||
}, []);
|
||||
const openSubtaskWithSession = useCallback((sessionId: string) => {
|
||||
setSubtaskWorkflowId(undefined);
|
||||
setSubtaskResumeSessionId(sessionId);
|
||||
setIsSubtaskOpen(true);
|
||||
}, []);
|
||||
@@ -262,6 +275,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
setIsSubtaskOpen(false);
|
||||
setSubtaskInitialDescription(null);
|
||||
setSubtaskResumeSessionId(undefined);
|
||||
setSubtaskWorkflowId(undefined);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -423,9 +437,11 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
isPlanningOpen,
|
||||
planningInitialPlan,
|
||||
planningResumeSessionId,
|
||||
planningWorkflowId,
|
||||
isSubtaskOpen,
|
||||
subtaskInitialDescription,
|
||||
subtaskResumeSessionId,
|
||||
subtaskWorkflowId,
|
||||
detailTask,
|
||||
detailTaskInitialTab,
|
||||
detailTaskOrigin,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import type { WorkflowIr } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
type PlanningSession = {
|
||||
summary: {
|
||||
title: string;
|
||||
description: string;
|
||||
suggestedSize: "S" | "M" | "L";
|
||||
priority: "normal";
|
||||
suggestedDependencies: string[];
|
||||
keyDeliverables: string[];
|
||||
};
|
||||
initialPlan: string;
|
||||
history: Array<{ role: string; content: string }>;
|
||||
};
|
||||
|
||||
type SubtaskSession = {
|
||||
initialDescription: string;
|
||||
autoMerge?: boolean;
|
||||
};
|
||||
|
||||
const planningSessions = new Map<string, PlanningSession>();
|
||||
const subtaskSessions = new Map<string, SubtaskSession>();
|
||||
|
||||
vi.mock("../../planning.js", () => ({
|
||||
getSession: (id: string) => planningSessions.get(id),
|
||||
getSummary: (id: string) => planningSessions.get(id)?.summary,
|
||||
releaseSession: vi.fn(),
|
||||
cleanupSession: vi.fn(),
|
||||
formatInterviewQA: vi.fn(() => ""),
|
||||
mergePlanningSubtaskDrafts: vi.fn((_sessionId: string, subtasks: unknown[]) => subtasks),
|
||||
}));
|
||||
|
||||
vi.mock("../../subtask-breakdown.js", () => ({
|
||||
getSubtaskSession: (id: string) => subtaskSessions.get(id),
|
||||
cleanupSubtaskSession: vi.fn(),
|
||||
}));
|
||||
|
||||
function linearIr(name: string): WorkflowIr {
|
||||
return {
|
||||
version: "v1",
|
||||
name,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } },
|
||||
{ id: "spec", kind: "prompt", config: { name: "Spec", prompt: "check" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "lint", condition: "success" },
|
||||
{ from: "lint", to: "spec", condition: "success" },
|
||||
{ from: "spec", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function seedPlanningSession(id: string, title = "Planned task"): void {
|
||||
planningSessions.set(id, {
|
||||
summary: {
|
||||
title,
|
||||
description: `${title} description`,
|
||||
suggestedSize: "M",
|
||||
priority: "normal",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: [],
|
||||
},
|
||||
initialPlan: title,
|
||||
history: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe("planning and subtask create routes workflowId", () => {
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
planningSessions.clear();
|
||||
subtaskSessions.clear();
|
||||
rootDir = mkdtempSync(join(tmpdir(), "planning-subtask-wf-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "planning-subtask-wf-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (path: string, body: unknown) =>
|
||||
REQUEST(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" });
|
||||
|
||||
it("POST /planning/create-task assigns the supplied workflowId", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Planning QA", ir: linearIr("planning-qa") });
|
||||
seedPlanningSession("plan-single", "Single planning task");
|
||||
|
||||
const res = await post("/api/planning/create-task", { sessionId: "plan-single", workflowId: wf.id });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.getTaskWorkflowSelection((res.body as { id: string }).id)?.workflowId).toBe(wf.id);
|
||||
});
|
||||
|
||||
it("POST /planning/create-tasks assigns the supplied workflowId to every created task", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Planning multi QA", ir: linearIr("planning-multi-qa") });
|
||||
seedPlanningSession("plan-multi", "Multi planning task");
|
||||
|
||||
const res = await post("/api/planning/create-tasks", {
|
||||
planningSessionId: "plan-multi",
|
||||
workflowId: wf.id,
|
||||
subtasks: [
|
||||
{ id: "tmp-1", title: "First child", description: "First child description" },
|
||||
{ id: "tmp-2", title: "Second child", description: "Second child description" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const tasks = (res.body as { tasks: Array<{ id: string }> }).tasks;
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks.map((task) => store.getTaskWorkflowSelection(task.id)?.workflowId)).toEqual([wf.id, wf.id]);
|
||||
});
|
||||
|
||||
it("POST /subtasks/create-tasks assigns the supplied workflowId to every created child", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Subtask QA", ir: linearIr("subtask-qa") });
|
||||
subtaskSessions.set("subtask-session", { initialDescription: "Break this down" });
|
||||
|
||||
const res = await post("/api/subtasks/create-tasks", {
|
||||
sessionId: "subtask-session",
|
||||
workflowId: wf.id,
|
||||
subtasks: [
|
||||
{ tempId: "tmp-1", title: "First split", description: "First split description" },
|
||||
{ tempId: "tmp-2", title: "Second split", description: "Second split description" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const tasks = (res.body as { tasks: Array<{ id: string }> }).tasks;
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks.map((task) => store.getTaskWorkflowSelection(task.id)?.workflowId)).toEqual([wf.id, wf.id]);
|
||||
});
|
||||
|
||||
it("omitting workflowId preserves default-workflow inheritance", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Default QA", ir: linearIr("default-qa") });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
seedPlanningSession("plan-default", "Default inherited task");
|
||||
|
||||
const res = await post("/api/planning/create-task", { sessionId: "plan-default" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.getTaskWorkflowSelection((res.body as { id: string }).id)?.workflowId).toBe(wf.id);
|
||||
});
|
||||
|
||||
it("unknown workflowId returns a 4xx instead of a 500 for all create routes", async () => {
|
||||
seedPlanningSession("plan-bad-single", "Bad single");
|
||||
seedPlanningSession("plan-bad-multi", "Bad multi");
|
||||
subtaskSessions.set("subtask-bad", { initialDescription: "Bad subtask" });
|
||||
|
||||
const requests = [
|
||||
post("/api/planning/create-task", { sessionId: "plan-bad-single", workflowId: "WF-404" }),
|
||||
post("/api/planning/create-tasks", {
|
||||
planningSessionId: "plan-bad-multi",
|
||||
workflowId: "WF-404",
|
||||
subtasks: [{ id: "tmp-1", title: "Bad child", description: "Bad child description" }],
|
||||
}),
|
||||
post("/api/subtasks/create-tasks", {
|
||||
sessionId: "subtask-bad",
|
||||
workflowId: "WF-404",
|
||||
subtasks: [{ tempId: "tmp-1", title: "Bad split", description: "Bad split description" }],
|
||||
}),
|
||||
];
|
||||
|
||||
for (const res of await Promise.all(requests)) {
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,27 @@ interface PlanningSubtaskRouteDeps {
|
||||
replayBufferedSSE: (res: import("express").Response, bufferedEvents: SessionBufferedEvent[]) => boolean;
|
||||
}
|
||||
|
||||
function rethrowPlanningWorkflowCreateError(
|
||||
err: unknown,
|
||||
fallbackMessage: string,
|
||||
rethrowAsApiError: ApiRoutesContext["rethrowAsApiError"],
|
||||
): never {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err || fallbackMessage);
|
||||
const isWorkflowClientError =
|
||||
/^Workflow '.*' not found$/.test(message)
|
||||
|| /is a fragment and cannot be selected/.test(message);
|
||||
|
||||
if (isWorkflowClientError) {
|
||||
throw new ApiError(400, message);
|
||||
}
|
||||
|
||||
rethrowAsApiError(err, fallbackMessage);
|
||||
}
|
||||
|
||||
export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: PlanningSubtaskRouteDeps): void {
|
||||
const { router, getProjectContext, planningLogger, rethrowAsApiError } = ctx;
|
||||
const { aiSessionStore, checkSessionLock, parseLastEventId, replayBufferedSSE } = deps;
|
||||
@@ -173,7 +194,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
|
||||
router.post("/subtasks/create-tasks", async (req, res) => {
|
||||
try {
|
||||
const { sessionId, subtasks, parentTaskId, branch, baseBranch, branchSelection, branchAssignment } = req.body as {
|
||||
const { sessionId, subtasks, parentTaskId, branch, baseBranch, branchSelection, branchAssignment, workflowId } = req.body as {
|
||||
sessionId?: string;
|
||||
subtasks?: Array<{ tempId: string; title: string; description: string; size?: "S" | "M" | "L"; dependsOn?: string[] }>;
|
||||
parentTaskId?: string;
|
||||
@@ -181,6 +202,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
baseBranch?: unknown;
|
||||
branchSelection?: unknown;
|
||||
branchAssignment?: unknown;
|
||||
workflowId?: unknown;
|
||||
};
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
@@ -191,6 +213,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
throw badRequest("subtasks must be a non-empty array");
|
||||
}
|
||||
|
||||
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
|
||||
throw badRequest("workflowId must be a string or null");
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { getSubtaskSession, cleanupSubtaskSession } = await import("../subtask-breakdown.js");
|
||||
const session = getSubtaskSession(sessionId);
|
||||
@@ -271,6 +297,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
branch: taskBranch,
|
||||
baseBranch: resolvedBaseBranch,
|
||||
branchContext: planningBranchContext,
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Tasks created from a workflow lane via subtask breakdown must stay on that active workflow instead of falling back to the project default board.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
|
||||
});
|
||||
|
||||
tempIdToTaskId.set(item.tempId, task.id);
|
||||
@@ -362,10 +393,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
droppedDependencies,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to create tasks from breakdown");
|
||||
rethrowPlanningWorkflowCreateError(err, "Failed to create tasks from breakdown", rethrowAsApiError);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1025,18 +1053,23 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
*/
|
||||
router.post("/planning/create-task", async (req, res) => {
|
||||
try {
|
||||
const { sessionId, summary: summaryInput, branch, baseBranch, branchSelection } = req.body as {
|
||||
const { sessionId, summary: summaryInput, branch, baseBranch, branchSelection, workflowId } = req.body as {
|
||||
sessionId?: unknown;
|
||||
summary?: unknown;
|
||||
branch?: unknown;
|
||||
baseBranch?: unknown;
|
||||
branchSelection?: unknown;
|
||||
workflowId?: unknown;
|
||||
};
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
|
||||
throw badRequest("workflowId must be a string or null");
|
||||
}
|
||||
|
||||
const summaryOverride = parsePlanningSummaryOverride(summaryInput);
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
@@ -1135,6 +1168,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
source: { sourceType: "api" },
|
||||
branch: resolvedBranch,
|
||||
baseBranch: resolvedBaseBranch,
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Planning Mode creates tasks from the board context, so an active workflow lane must be materialized at create time when the client supplies it.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
|
||||
});
|
||||
|
||||
// Update task with suggested size if provided.
|
||||
@@ -1164,10 +1202,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
|
||||
res.status(201).json(task);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to create task");
|
||||
rethrowPlanningWorkflowCreateError(err, "Failed to create task", rethrowAsApiError);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1229,7 +1264,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
*/
|
||||
router.post("/planning/create-tasks", async (req, res) => {
|
||||
try {
|
||||
const { planningSessionId, subtasks, branch, baseBranch, branchSelection, branchAssignment } = req.body as {
|
||||
const { planningSessionId, subtasks, branch, baseBranch, branchSelection, branchAssignment, workflowId } = req.body as {
|
||||
planningSessionId?: string;
|
||||
subtasks?: Array<{
|
||||
id: string;
|
||||
@@ -1243,6 +1278,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
baseBranch?: unknown;
|
||||
branchSelection?: unknown;
|
||||
branchAssignment?: unknown;
|
||||
workflowId?: unknown;
|
||||
};
|
||||
|
||||
if (!planningSessionId || typeof planningSessionId !== "string") {
|
||||
@@ -1253,6 +1289,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
throw badRequest("subtasks must be a non-empty array");
|
||||
}
|
||||
|
||||
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
|
||||
throw badRequest("workflowId must be a string or null");
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { getSession, cleanupSession, formatInterviewQA, mergePlanningSubtaskDrafts } = await import("../planning.js");
|
||||
|
||||
@@ -1364,6 +1404,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
branch: taskBranch,
|
||||
baseBranch: resolvedBaseBranch,
|
||||
branchContext: planningBranchContext,
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Multi-task Planning Mode creation must apply the selected workflow to every generated child so saved tasks do not jump to the main board first.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
|
||||
});
|
||||
|
||||
tempIdToTaskId.set(item.id, task.id);
|
||||
@@ -1411,10 +1456,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
|
||||
res.status(201).json({ tasks: createdTasks });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to create tasks from planning");
|
||||
rethrowPlanningWorkflowCreateError(err, "Failed to create tasks from planning", rethrowAsApiError);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user