From 0a50e2142dcbf19fd0784d13a5832f39803a2174 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 17 Aug 2026 20:54:43 -0700 Subject: [PATCH] FN-9143: inherit selected workflow in New Task dialog Make every New Task entry point seed the dialog from the active Board or List workflow. - Resolve implicit workflow selection through the modal manager while preserving explicit choices and All workflows behavior. - Forward selected workflow IDs from List view and navigation-backed dialog entry points. - Add cross-surface regression coverage, operator documentation, and a patch changeset. Files changed: .changeset/fn-9143-new-task-selected-workflow.md | 7 +++ docs/dashboard-guide.md | 2 +- packages/dashboard/app/App.tsx | 4 +- packages/dashboard/app/components/AppModals.tsx | 4 +- packages/dashboard/app/components/Column.tsx | 4 +- .../dashboard/app/components/LeftSidebarNav.tsx | 4 +- packages/dashboard/app/components/ListView.tsx | 7 ++- .../components/__tests__/LeftSidebarNav.test.tsx | 1 + .../app/components/__tests__/ListView.test.tsx | 13 +++-- .../new-task-dialog-selected-workflow.test.tsx | 46 ++++++++++++++++++ .../dashboard/app/components/dashboard/types.ts | 2 +- .../app/hooks/__tests__/useModalManager.test.ts | 55 ++++++++++++++++++++++ packages/dashboard/app/hooks/useModalManager.ts | 19 ++++++-- 13 files changed, 148 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-9143 Fusion-Task-Lineage: 43951c5d-24d2-4243-826a-c6f416607882 Co-authored-by: Fusion (runfusion.ai) --- .../fn-9143-new-task-selected-workflow.md | 7 +++ docs/dashboard-guide.md | 2 +- packages/dashboard/app/App.tsx | 4 +- .../dashboard/app/components/AppModals.tsx | 4 +- packages/dashboard/app/components/Column.tsx | 4 +- .../app/components/LeftSidebarNav.tsx | 4 +- .../dashboard/app/components/ListView.tsx | 7 ++- .../__tests__/LeftSidebarNav.test.tsx | 1 + .../components/__tests__/ListView.test.tsx | 13 +++-- ...new-task-dialog-selected-workflow.test.tsx | 46 ++++++++++++++++ .../app/components/dashboard/types.ts | 2 +- .../hooks/__tests__/useModalManager.test.ts | 55 +++++++++++++++++++ .../dashboard/app/hooks/useModalManager.ts | 19 +++++-- 13 files changed, 148 insertions(+), 20 deletions(-) create mode 100644 .changeset/fn-9143-new-task-selected-workflow.md create mode 100644 packages/dashboard/app/components/__tests__/new-task-dialog-selected-workflow.test.tsx diff --git a/.changeset/fn-9143-new-task-selected-workflow.md b/.changeset/fn-9143-new-task-selected-workflow.md new file mode 100644 index 0000000000..22e6da2e9f --- /dev/null +++ b/.changeset/fn-9143-new-task-selected-workflow.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: New Task now inherits the workflow selected in Board or List. +category: fix +dev: Routes New Task opens through the useModalManager.openNewTask inheritance seam. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 3f16595c58..b9513a55db 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -482,7 +482,7 @@ Behavior: Workflows define how a task moves through planning, execution, review, workflow steps, merge, and any custom graph policy. Most coding tasks can stay on the default Coding workflow, but task and board workflow controls can select a different built-in or custom workflow per task. For the built-in catalog and runtime semantics, see [Workflow Steps → Workflow overview](./workflow-steps.md#workflow-overview). -When creating a task from the full **New Task** dialog, the **Workflow** advanced control opens a styled dropdown instead of a native select. Built-in workflows show the Fusion mark, custom workflows show their configured compact icon when present, **No workflow** remains the explicit opt-out, and leaving the picker untouched still inherits the project/default workflow. +When creating a task from the full **New Task** dialog, the **Workflow** advanced control opens a styled dropdown instead of a native select. Built-in workflows show the Fusion mark, custom workflows show their configured compact icon when present, **No workflow** remains the explicit opt-out, and leaving the picker untouched still inherits the project/default workflow. Opening the dialog while viewing a specific Board or List workflow preselects that workflow across lane, sidebar, keyboard-shortcut, and description-seeded entry points; opening from **All workflows** instead leaves the picker unset so the project default applies. Optional workflow steps can be toggled from the task **Edit** form's **More options → Workflow Steps** control or from the task's **Workflow** tab. The edit form uses the task's resolved workflow and preserves the task's current stored selection when it opens; workflow-authored `defaultOn` values remain a create-time/runtime default, not an edit-form re-seed. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index f53d97c8ab..8095326d4c 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1249,8 +1249,8 @@ function AppInner() { handleTaskViewChange("command-center"); }, [handleTaskViewChange]); - const openNewTaskWithNav = useCallback(() => { - modalManager.openNewTask(); + const openNewTaskWithNav = useCallback((workflowId?: string | null) => { + modalManager.openNewTask(workflowId); pushNav({ type: "modal", close: modalManager.closeNewTask }); }, [modalManager, pushNav]); diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 18175036a8..ce04613e54 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -232,8 +232,8 @@ export function AppModals({ modalManager.closeSetupWizard(); }, [modalManager.closeSetupWizard, removeNav]); - const handleOpenNewTask = useCallback(() => { - modalManager.openNewTask(); + const handleOpenNewTask = useCallback((workflowId?: string | null) => { + modalManager.openNewTask(workflowId); }, [modalManager]); const handleOpenGitHubImport = useCallback(() => { diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 19d4f482bf..96eea8f83f 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -130,7 +130,7 @@ interface ColumnProps { onOpenGroupModal?: (groupId: string) => void; addToast: (message: string, type?: ToastType) => void; onQuickCreate?: (input: TaskCreateInput) => Promise; - onNewTask?: () => void; + onNewTask?: (workflowId?: string | null) => void; autoMerge?: boolean; /** Project merge strategy for Task Detail-equivalent card context actions. */ mergeStrategy?: string; @@ -924,7 +924,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree )} {onNewTask && ( - )} diff --git a/packages/dashboard/app/components/LeftSidebarNav.tsx b/packages/dashboard/app/components/LeftSidebarNav.tsx index 17cb883ac6..885470c37f 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.tsx +++ b/packages/dashboard/app/components/LeftSidebarNav.tsx @@ -105,7 +105,7 @@ function persistCollapsed(collapsed: boolean): void { export interface LeftSidebarNavProps { view: TaskView; onChangeView: (view: TaskView) => void; - onNewTask?: () => void; + onNewTask?: (workflowId?: string | null) => void; onOpenSettings?: () => void; mailboxUnreadCount?: number; mailboxPendingApprovalCount?: number; @@ -504,7 +504,7 @@ export function LeftSidebarNav({ aria-label={newTaskLabel} title={newTaskLabel} data-testid="sidebar-nav-new-task" - onClick={onNewTask} + onClick={() => onNewTask()} > {newTaskLabel} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index d9962d4116..1fb1761024 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -284,7 +284,7 @@ interface ListViewProps { openMobileTasksInPopup?: boolean; addToast: (message: string, type?: ToastType) => void; globalPaused?: boolean; - onNewTask?: () => void; + onNewTask?: (workflowId?: string | null) => void; onQuickCreate?: (input: TaskCreateInput) => Promise; availableModels?: ModelInfo[]; favoriteProviders?: string[]; @@ -2814,7 +2814,10 @@ export function ListView({ {t("listView.viewOptions", "View")} {onNewTask ? ( - ) : null} diff --git a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx index 1e9e158e9e..a05360ff5f 100644 --- a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx +++ b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx @@ -156,6 +156,7 @@ describe("LeftSidebarNav", () => { fireEvent.click(newTaskButton); expect(onNewTask).toHaveBeenCalledOnce(); + expect(onNewTask).toHaveBeenCalledWith(); }); it("omits the New Task CTA when no trigger prop is provided", () => { diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 81859d2bc3..00d08782f9 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -1914,6 +1914,7 @@ describe("ListView", () => { it("shows all workflows in ListView without submitting the aggregate sentinel", async () => { const mockOnQuickCreate = vi.fn().mockResolvedValue({ id: "FN-new" }); + const mockOnNewTask = vi.fn(); vi.mocked(fetchBoardWorkflows).mockResolvedValue({ flagEnabled: true, defaultWorkflowId: "builtin:coding", @@ -1931,6 +1932,7 @@ describe("ListView", () => { createMockTask({ id: "FN-003", column: "triage", title: "Stale workflow task" }), ], onQuickCreate: mockOnQuickCreate, + onNewTask: mockOnNewTask, }); await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID); @@ -1941,6 +1943,9 @@ describe("ListView", () => { expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("All workflows"); expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull(); + fireEvent.click(screen.getByText("+ New Task")); + expect(mockOnNewTask).toHaveBeenCalledWith(undefined); + fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Aggregate quick add" } }); fireEvent.keyDown(screen.getByTestId("quick-entry-input"), { key: "Enter" }); @@ -3204,17 +3209,17 @@ describe("ListView", () => { expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(1); }); - it("calls onNewTask when + New Task button is clicked", () => { + it("forwards the selected workflow rather than a click event when + New Task is clicked", () => { const mockOnNewTask = vi.fn(); renderListView({ onNewTask: mockOnNewTask }); - const newTaskButton = screen.getByText("+ New Task"); - fireEvent.click(newTaskButton); + fireEvent.click(screen.getByText("+ New Task")); - expect(mockOnNewTask).toHaveBeenCalled(); + expect(mockOnNewTask).toHaveBeenCalledWith("builtin:coding"); }); + it("keeps Bulk Edit, View, and + New Task together in the desktop sidebar controls", () => { renderListView({}, { openViewOptions: false }); diff --git a/packages/dashboard/app/components/__tests__/new-task-dialog-selected-workflow.test.tsx b/packages/dashboard/app/components/__tests__/new-task-dialog-selected-workflow.test.tsx new file mode 100644 index 0000000000..3bf65bfc90 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/new-task-dialog-selected-workflow.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useModalManager } from "../../hooks/useModalManager"; +import { + ALL_WORKFLOWS_BOARD_VIEW_ID, + BOARD_WORKFLOW_SELECTION_STORAGE_KEY, +} from "../../utils/boardWorkflowSelection"; +import { scopedKey } from "../../utils/projectStorage"; + +const projectId = "new-task-workflow-project"; +const selectedWorkflowId = "workflow-ideas"; +const selectionKey = scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId); + +describe("New Task dialog selected-workflow inheritance", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("preserves the selected workflow across board, list, sidebar, shortcut, and description opens", () => { + localStorage.setItem(selectionKey, selectedWorkflowId); + const { result } = renderHook(() => useModalManager({ projectId, planningSessions: [] })); + + const surfaces: Array<[string, () => void]> = [ + ["board lane", () => result.current.openNewTask(selectedWorkflowId)], + ["list", () => result.current.openNewTask(selectedWorkflowId)], + ["sidebar CTA", () => result.current.openNewTask()], + ["keyboard shortcut", () => result.current.openNewTask()], + ["description-seeded", () => result.current.openNewTaskWithDescription("Create from selection")], + ]; + + for (const [, open] of surfaces) { + act(open); + expect(result.current.newTaskInitialWorkflowId).toBe(selectedWorkflowId); + act(() => result.current.closeNewTask()); + } + }); + + it("does not forward the aggregate board sentinel into the dialog", () => { + localStorage.setItem(selectionKey, ALL_WORKFLOWS_BOARD_VIEW_ID); + const { result } = renderHook(() => useModalManager({ projectId, planningSessions: [] })); + + act(() => result.current.openNewTask()); + + expect(result.current.newTaskInitialWorkflowId).toBeUndefined(); + }); +}); diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 00e6351371..744f9c5713 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -190,7 +190,7 @@ export interface MainContentProps { openTaskDetailInMainPanel: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void; openGroupModalWithNav: (groupId: string) => void; handleBoardQuickCreate: (input: TaskCreateInput) => Promise; - openNewTaskWithNav: () => void; + openNewTaskWithNav: (workflowId?: string | null) => void; subtaskBreakdownEnabled: boolean; openSubtaskBreakdownWithNav: (description: string, workflowId?: string | null) => void; toggleAutoMerge: () => Promise; diff --git a/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts b/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts index 6aa85fe56d..ddfeb99324 100644 --- a/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts @@ -3,6 +3,10 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import type { Task, TaskDetail } from "@fusion/core"; import { useModalManager } from "../useModalManager"; import { scopedKey } from "../../utils/projectStorage"; +import { + ALL_WORKFLOWS_BOARD_VIEW_ID, + BOARD_WORKFLOW_SELECTION_STORAGE_KEY, +} from "../../utils/boardWorkflowSelection"; function createTaskDetail(id: string): TaskDetail { return { @@ -129,6 +133,57 @@ describe("useModalManager", () => { expect(result.current.settingsOpen).toBe(true); }); + it("inherits the selected board workflow for new task opens while preserving explicit choices", () => { + const projectId = "proj_1"; + const selectionKey = scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId); + const { result } = renderHook(() => + useModalManager({ projectId, planningSessions: [] }), + ); + + act(() => { + result.current.openNewTask(); + }); + expect(result.current.newTaskInitialWorkflowId).toBeUndefined(); + + localStorage.setItem(selectionKey, "coding"); + act(() => { + result.current.openNewTask(); + }); + expect(result.current.newTaskInitialWorkflowId).toBe("coding"); + + act(() => { + result.current.openNewTask("explicit"); + }); + expect(result.current.newTaskInitialWorkflowId).toBe("explicit"); + + act(() => { + result.current.openNewTask(null); + }); + expect(result.current.newTaskInitialWorkflowId).toBeNull(); + + act(() => { + result.current.openNewTask({ type: "click" } as never); + }); + expect(result.current.newTaskInitialWorkflowId).toBe("coding"); + + localStorage.setItem(selectionKey, ALL_WORKFLOWS_BOARD_VIEW_ID); + act(() => { + result.current.openNewTask(); + }); + expect(result.current.newTaskInitialWorkflowId).toBeUndefined(); + + localStorage.setItem(selectionKey, "coding"); + act(() => { + result.current.openNewTaskWithDescription("draft"); + }); + expect(result.current.newTaskInitialWorkflowId).toBe("coding"); + + act(() => { + result.current.closeNewTask(); + }); + expect(result.current.newTaskInitialWorkflowId).toBeUndefined(); + }); + it("opens the new task modal with a seeded description and resets it on close", () => { const { result } = renderHook(() => useModalManager({ projectId: "proj_1", planningSessions: [] }), diff --git a/packages/dashboard/app/hooks/useModalManager.ts b/packages/dashboard/app/hooks/useModalManager.ts index abd416fadb..6c3e467a89 100644 --- a/packages/dashboard/app/hooks/useModalManager.ts +++ b/packages/dashboard/app/hooks/useModalManager.ts @@ -4,6 +4,7 @@ import type { Task, TaskDetail } from "@fusion/core"; import type { SectionId } from "../components/SettingsModal"; import type { ToastType } from "./useToast"; import { removeScopedItem } from "../utils/projectStorage"; +import { readBoardWorkflowSelection } from "../utils/boardWorkflowSelection"; import { applyLocalTaskPatch } from "./useTasks"; /* @@ -292,16 +293,26 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { modelOnboardingOpen, ); + /* + FNXC:TaskWorkflowSelection 2026-08-18-03:33: + The New Task dialog must inherit the currently selected board workflow on every open surface. + Explicit workflow ids and explicit null take precedence, while the aggregate sentinel never crosses + this task-creation boundary. + */ + const resolveNewTaskInitialWorkflowId = useCallback((workflowId: unknown): string | null | undefined => { + if (typeof workflowId === "string" || workflowId === null) return workflowId; + return readBoardWorkflowSelection(options.projectId) ?? undefined; + }, [options.projectId]); const openNewTask = useCallback((workflowId?: string | null) => { setNewTaskInitialDescription(null); - setNewTaskInitialWorkflowId(workflowId); + setNewTaskInitialWorkflowId(resolveNewTaskInitialWorkflowId(workflowId)); setNewTaskModalOpen(true); - }, []); + }, [resolveNewTaskInitialWorkflowId]); const openNewTaskWithDescription = useCallback((description: string) => { setNewTaskInitialDescription(description); - setNewTaskInitialWorkflowId(undefined); + setNewTaskInitialWorkflowId(resolveNewTaskInitialWorkflowId(undefined)); setNewTaskModalOpen(true); - }, []); + }, [resolveNewTaskInitialWorkflowId]); const closeNewTask = useCallback(() => { setNewTaskModalOpen(false); setNewTaskInitialDescription(null);