From 36b8950dbed6a9f574aaaa4d40602979ffe08a72 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 18:04:02 -0700 Subject: [PATCH] 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 --- .../planning-subtask-workflow-selection.md | 5 + docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 8 +- packages/dashboard/app/api/legacy.ts | 6 + .../dashboard/app/components/AppModals.tsx | 2 + packages/dashboard/app/components/Board.tsx | 4 +- packages/dashboard/app/components/Column.tsx | 5 +- .../app/components/InlineCreateCard.tsx | 20 +- packages/dashboard/app/components/Lane.tsx | 4 +- .../dashboard/app/components/ListView.tsx | 4 +- .../app/components/PlanningModeModal.tsx | 18 +- .../app/components/QuickEntryBox.tsx | 24 ++- .../app/components/SubtaskBreakdownModal.tsx | 20 +- .../dashboard/app/components/TaskForm.tsx | 4 +- .../__tests__/QuickEntryBox.test.tsx | 34 ++++ .../dashboard/app/hooks/useModalManager.ts | 26 ++- ...ning-subtask-create-workflow-route.test.ts | 192 ++++++++++++++++++ .../register-planning-subtask-routes.ts | 72 +++++-- 18 files changed, 398 insertions(+), 52 deletions(-) create mode 100644 .changeset/planning-subtask-workflow-selection.md create mode 100644 packages/dashboard/src/routes/__tests__/planning-subtask-create-workflow-route.test.ts diff --git a/.changeset/planning-subtask-workflow-selection.md b/.changeset/planning-subtask-workflow-selection.md new file mode 100644 index 0000000000..7e074a038e --- /dev/null +++ b/.changeset/planning-subtask-workflow-selection.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 9317f36997..3cf74dc7ed 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index aaee296ab6..fed57307fd 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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]); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 3f9faf891c..e09ba58744 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -3645,6 +3645,7 @@ export function createTaskFromPlanning( branchName?: string; baseBranch?: string; }; + workflowId?: string | null; }, ): Promise { return api(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, diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index c8b0de8c6c..97b5ce9050 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -359,6 +359,7 @@ export function AppModals({ tasks={tasks} initialPlan={modalManager.planningInitialPlan ?? undefined} projectId={projectId} + workflowId={modalManager.planningWorkflowId} resumeSessionId={modalManager.planningResumeSessionId} /> @@ -370,6 +371,7 @@ export function AppModals({ initialDescription={modalManager.subtaskInitialDescription ?? ""} onTasksCreated={taskHandlers.handleSubtaskTasksCreated} projectId={projectId} + workflowId={modalManager.subtaskWorkflowId} resumeSessionId={modalManager.subtaskResumeSessionId} onOpenGroupModal={openGroupModalWithNav} /> diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index d64fbd5335..c6a87cbc23 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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[]; diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 011dd7e56c..4eaa9057c1 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -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} diff --git a/packages/dashboard/app/components/InlineCreateCard.tsx b/packages/dashboard/app/components/InlineCreateCard.tsx index 82d689cd12..1376695c8d 100644 --- a/packages/dashboard/app/components/InlineCreateCard.tsx +++ b/packages/dashboard/app/components/InlineCreateCard.tsx @@ -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; diff --git a/packages/dashboard/app/components/Lane.tsx b/packages/dashboard/app/components/Lane.tsx index 1c9d1289f1..0479f755a6 100644 --- a/packages/dashboard/app/components/Lane.tsx +++ b/packages/dashboard/app/components/Lane.tsx @@ -55,8 +55,8 @@ export interface LaneProps { githubIssueAction?: GithubIssueAction; }) => Promise; 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[]; diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 7c796987f0..7ce242db90 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -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. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 65e92d6c7c..63eeac4625 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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({ 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) { diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index f5a8b1d2e8..773e8e5920 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -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) diff --git a/packages/dashboard/app/components/SubtaskBreakdownModal.tsx b/packages/dashboard/app/components/SubtaskBreakdownModal.tsx index 037ff9e1a7..1427894392 100644 --- a/packages/dashboard/app/components/SubtaskBreakdownModal.tsx +++ b/packages/dashboard/app/components/SubtaskBreakdownModal.tsx @@ -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") { diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index 805611d09a..7733f73cf3 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -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. */ diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index 7c54164abc..057b9a5872 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -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(); diff --git a/packages/dashboard/app/hooks/useModalManager.ts b/packages/dashboard/app/hooks/useModalManager.ts index 91faf57be6..57c1ef8f6f 100644 --- a/packages/dashboard/app/hooks/useModalManager.ts +++ b/packages/dashboard/app/hooks/useModalManager.ts @@ -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(null); const [planningResumeSessionId, setPlanningResumeSessionId] = useState(undefined); + const [planningWorkflowId, setPlanningWorkflowId] = useState(undefined); const [isSubtaskOpen, setIsSubtaskOpen] = useState(false); const [subtaskInitialDescription, setSubtaskInitialDescription] = useState(null); const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState(undefined); + const [subtaskWorkflowId, setSubtaskWorkflowId] = useState(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, diff --git a/packages/dashboard/src/routes/__tests__/planning-subtask-create-workflow-route.test.ts b/packages/dashboard/src/routes/__tests__/planning-subtask-create-workflow-route.test.ts new file mode 100644 index 0000000000..7a37940c44 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/planning-subtask-create-workflow-route.test.ts @@ -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(); +const subtaskSessions = new Map(); + +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); + } + }); +}); diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 9e9483e6f8..2339307cca 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -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); } });