diff --git a/.changeset/quick-add-start-duplicated-ideas-workflow.md b/.changeset/quick-add-start-duplicated-ideas-workflow.md new file mode 100644 index 0000000000..9ae3c23970 --- /dev/null +++ b/.changeset/quick-add-start-duplicated-ideas-workflow.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Start in the task composer doing nothing on a duplicated Ideas workflow. +category: fix +dev: `resolveQuickAddStartInitialColumn` no longer keys on the literal `builtin:coding-ideas` id — a manual-intake workflow now resolves its create-time Planning lane from traits (first declared `hold` column immediately after the intake), mirroring `resolveWorkflowIntakeFacts`'s unplanned-Start classification in `packages/core/src/task-store/task-creation.ts`. `resolveQuickAddStartTargetColumn` promotes exactly one legal forward step (hold lanes included) instead of skipping holds into the WIP lane, which column adjacency always rejected (`intake -> hold | archived`). Covers both Start surfaces: QuickEntryBox and NewTaskModal. diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 85cb4ea345..5d6dc7c335 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -1785,6 +1785,43 @@ describe("NewTaskModal", () => { expect(props.addToast).toHaveBeenCalledWith("Queued FN-START for planning", "success"); }); + /* + FNXC:QuickAddStart 2026-08-26-19:19: + Second Start surface for the duplicated-Ideas-workflow fix: the New Task dialog shares + `quickAddStart`'s resolution with the board composer, so it must also create in the duplicate's + own Planning lane instead of promoting into the WIP lane (a rejected transition that left the + card parked in Ideas). + */ + it("atomically creates a duplicated Ideas workflow's Start in its own Planning lane", async () => { + await mockStartWorkflows("WF-014", "Coding ideas V2"); + vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [{ + id: "WF-014", + name: "Coding ideas V2", + columns: [ + { id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } }, + { id: "todo", name: "Planning", flags: { hold: true } }, + { id: "in-progress", name: "In progress", flags: { countsTowardWip: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ], + }], + taskWorkflowIds: {}, + } as BoardWorkflowsPayload); + const onCreateTask = vi.fn().mockResolvedValue({ ...makeTask("FN-CLONE"), column: "todo", workflowId: "WF-014" }); + const onMoveTask = vi.fn(); + renderNewTaskModal({ onCreateTask, onMoveTask }); + + await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy()); + await chooseWorkflowOption("WF-014"); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Start this idea" } }); + fireEvent.click(await screen.findByTestId("task-form-inline-start")); + + await waitFor(() => expect(onCreateTask).toHaveBeenCalledWith(expect.objectContaining({ workflowId: "WF-014", column: "todo" }))); + expect(onMoveTask).not.toHaveBeenCalled(); + }); + it("exposes the same eligible Start action in the desktop floating host", async () => { mockViewportMode = "desktop"; await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)"); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index c1c0a9c76a..1bf3a7a471 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -5080,6 +5080,36 @@ describe("QuickEntryBox", () => { await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-hold", "working")); }); + /* + FNXC:QuickAddStart 2026-08-26-19:19: + Reported symptom: on a DUPLICATED Ideas workflow ("Coding ideas V2") the composer's Start button + created the card but never started it. Start keyed its atomic destination on the literal + `builtin:coding-ideas` id, so a copy fell through to a promotion that skipped the Planning hold + lane and moved into the WIP lane — a transition the server always rejects. Assert the composer + surface creates in the duplicate's own Planning lane and issues no move at all. + */ + it("creates a duplicated Ideas workflow's Start in its own Planning lane", async () => { + const duplicatedIdeasWorkflow = { + id: "WF-014", + name: "Coding ideas V2", + columns: [ + { id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } }, + { id: "todo", name: "Planning", flags: { hold: true } }, + { id: "in-progress", name: "In progress", flags: { countsTowardWip: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ], + }; + const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-clone", column: "todo", workflowId: duplicatedIdeasWorkflow.id }); + const onMoveTask = vi.fn().mockResolvedValue({}); + renderQuickEntryBox({ onCreate, onMoveTask, workflowId: duplicatedIdeasWorkflow.id, workflowOptions: [duplicatedIdeasWorkflow] }); + enterDescription(); + + clickStart(); + + await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: duplicatedIdeasWorkflow.id, column: "todo" }))); + expect(onMoveTask).not.toHaveBeenCalled(); + }); + it("disables Start until a description is entered and never hides it mid-typing", () => { renderQuickEntryBox({ onMoveTask: vi.fn(), workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] }); diff --git a/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts b/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts index bed6d3a8bf..d973a0989c 100644 --- a/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts +++ b/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts @@ -92,16 +92,94 @@ describe("quick add Start workflow guards", () => { expect(resolveQuickAddStartWorkflowTarget(noTarget)).toBeNull(); }); - it("only chooses a later visible working destination", () => { + /* + FNXC:QuickAddStart 2026-08-26-19:19: + Was "only chooses a later visible working destination", which asserted that the promotion skipped + `hold` lanes to reach the first working column. Column adjacency never permitted that jump, so the + move it described was rejected server-side. The promotion is one legal forward step. + */ + it("promotes exactly one legal forward step, hold lanes included", () => { const valid = validateQuickAddStartWorkflow(workflow({ columns: [ - { id: "ideas", name: "Ideas", flags: { hold: true } }, + { id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } }, { id: "review", name: "Review", flags: { hold: true } }, { id: "done", name: "Done", flags: { complete: true } }, { id: "todo", name: "Todo", flags: {} }, ] })); expect(valid).not.toBeNull(); - expect(resolveQuickAddStartTargetColumn(valid!, "ideas")).toBe("todo"); + expect(resolveQuickAddStartTargetColumn(valid!, "ideas")).toBe("review"); + expect(resolveQuickAddStartTargetColumn(valid!, "review")).toBeNull(); expect(resolveQuickAddStartTargetColumn(valid!, "todo")).toBeNull(); expect(resolveQuickAddStartTargetColumn(valid!, "unknown")).toBeNull(); }); + + /* + FNXC:QuickAddStart 2026-08-26-19:19: + Regression: a duplicated Ideas workflow ("Coding ideas V2") reported as "Start does not start the + task". Start resolved its destination from the literal `builtin:coding-ideas` id, so a copy fell + through to a promotion that skipped the Planning hold lane and targeted the WIP lane — a move + `intake -> wip` that column adjacency always rejects. Surfaces: both Start callers share these + helpers (QuickEntryBox composer and NewTaskModal), so the invariant is asserted here once for the + built-in, its duplicate, and the metadata shapes that must fail closed. + */ + describe("duplicated Ideas workflows", () => { + const clone = (overrides: Record = {}) => validateQuickAddStartWorkflow(workflow({ + id: "WF-014", + name: "Coding ideas V2", + columns: [ + { id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } }, + { id: "todo", name: "Planning", flags: { hold: true } }, + { id: "in-progress", name: "In progress", flags: { countsTowardWip: true } }, + { id: "in-review", name: "In review", flags: { mergeBlocker: true, humanReview: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + { id: "archived", name: "Archived", flags: { archived: true, hiddenFromBoard: true } }, + ], + ...overrides, + })); + + it("creates in its own Planning lane instead of jumping into the WIP lane", () => { + const duplicate = clone(); + expect(duplicate).not.toBeNull(); + expect(workflowSupportsQuickAddStart(duplicate)).toBe(true); + expect(resolveQuickAddStartInitialColumn(duplicate!)).toBe("todo"); + expect(resolveQuickAddStartWorkflowTarget(duplicate)).toBe("todo"); + // The rejected move that made Start a no-op must be unreachable from the intake lane. + expect(resolveQuickAddStartTargetColumn(duplicate!, "ideas")).not.toBe("in-progress"); + }); + + it("fails closed to the promotion path when the planning lane is unprovable", () => { + // The server classifies a Start create by the FIRST DECLARED hold column, so an intake that + // also holds, or a hidden earlier hold lane, means our visible candidate is the wrong column. + const intakeAlsoHolds = clone({ columns: [ + { id: "ideas", name: "Ideas", flags: { intake: true, hold: true, manualIntake: true } }, + { id: "todo", name: "Planning", flags: { hold: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ] }); + expect(resolveQuickAddStartInitialColumn(intakeAlsoHolds!)).toBeNull(); + expect(resolveQuickAddStartWorkflowTarget(intakeAlsoHolds)).toBe("todo"); + + const hiddenEarlierHold = clone({ columns: [ + { id: "parked", name: "Parked", flags: { hold: true, hiddenFromBoard: true } }, + { id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } }, + { id: "todo", name: "Planning", flags: { hold: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ] }); + expect(resolveQuickAddStartInitialColumn(hiddenEarlierHold!)).toBeNull(); + + const noPlanningLane = clone({ columns: [ + { id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } }, + { id: "in-progress", name: "In progress", flags: { countsTowardWip: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ] }); + expect(resolveQuickAddStartInitialColumn(noPlanningLane!)).toBeNull(); + expect(resolveQuickAddStartWorkflowTarget(noPlanningLane)).toBe("in-progress"); + + const autoTriagingIntake = clone({ columns: [ + { id: "ideas", name: "Ideas", flags: { intake: true } }, + { id: "todo", name: "Planning", flags: { hold: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ] }); + expect(resolveQuickAddStartInitialColumn(autoTriagingIntake!)).toBeNull(); + expect(resolveQuickAddStartWorkflowTarget(autoTriagingIntake)).toBeNull(); + }); + }); }); diff --git a/packages/dashboard/app/utils/quickAddStart.ts b/packages/dashboard/app/utils/quickAddStart.ts index 11ba6f030d..c321d2428b 100644 --- a/packages/dashboard/app/utils/quickAddStart.ts +++ b/packages/dashboard/app/utils/quickAddStart.ts @@ -45,16 +45,51 @@ export function workflowSupportsQuickAddStart(workflow: ValidatedQuickAddWorkflo * Custom hold-workflow Start promotion uses the returned task's actual column and moves forward only to * a later working column. Missing data, holds, complete lanes, or no later destination are * successful create-only outcomes; Quick Add never guesses `todo` or moves backwards. + * + * FNXC:QuickAddStart 2026-08-26-19:19: + * The forward step is now the IMMEDIATELY following visible column, and a `hold` lane is a legal + * destination rather than something to skip. Skipping holds produced a move the server always + * refuses: column adjacency permits `intake -> hold | archived` only (ROLE_TRANSITIONS in + * packages/core/src/workflows/workflow-transitions.ts), and neighbour-derived adjacency for + * genuinely custom shapes permits the next declared column only. Jumping over a Planning hold lane + * into the WIP lane therefore returned 409 "Invalid transition: 'ideas' -> 'in-progress'", so Start + * created a card that never started. One legal forward step is the only promotion Quick Add can + * prove; anything further is the operator's move to make. */ export function resolveQuickAddStartTargetColumn(workflow: ValidatedQuickAddWorkflow, createdColumn: unknown): string | null { if (typeof createdColumn !== "string" || !createdColumn.trim()) return null; const columns = visibleColumns(workflow); const createdIndex = columns.findIndex((column) => column.id === createdColumn); if (createdIndex < 0) return null; - for (const column of columns.slice(createdIndex + 1)) { - if (!column.flags.intake && !column.flags.hold && !column.flags.complete) return column.id; - } - return null; + const next = columns[createdIndex + 1]; + if (!next || next.flags.intake || next.flags.complete) return null; + return next.id; +} + +/* +FNXC:QuickAddStart 2026-08-26-19:19: +A DUPLICATED or hand-authored Ideas workflow ("Coding ideas V2") must start exactly like the +built-in one. The atomic create-in-Planning path below keys on the literal `builtin:coding-ideas` +id, so every copy fell through to the promotion path and its card stayed parked in Ideas. + +The destination is derived from the SAME traits the server uses, not from a name: a create lands in +the planning lane pre-planned only when `resolveWorkflowIntakeFacts` classifies it as an unplanned +Start create, and that check is `task.column === columnsWithFlag(ir, "hold")[0]` on a manual-intake +workflow (packages/core/src/task-store/task-creation.ts). Submitting any OTHER column earns +`generateSpecifiedPrompt` instead of the bootstrap seed, and triage only admits seed prompts — the +card would sit in Planning looking planned, forever (FN-8587). So the candidate must be the first +DECLARED hold column (hidden lanes included, exactly as the server scans them) and must sit +immediately after the manual intake. Anything else fails closed to the one-step promotion path, +which is legal under column adjacency. +*/ +function resolveManualIntakePlanningColumn(workflow: ValidatedQuickAddWorkflow): string | null { + const columns = visibleColumns(workflow); + const intake = columns[0]; + if (!intake || intake.flags.manualIntake !== true) return null; + const planning = columns[1]; + if (!planning || planning.flags.hold !== true || planning.flags.intake || planning.flags.complete) return null; + if (workflow.columns.find((column) => column.flags.hold === true)?.id !== planning.id) return null; + return planning.id; } /** @@ -80,7 +115,9 @@ export function resolveQuickAddStartWorkflowTarget(workflow: ValidatedQuickAddWo } export function resolveQuickAddStartInitialColumn(workflow: ValidatedQuickAddWorkflow): string | null { - if (workflow.id !== "builtin:coding-ideas") return null; + /* FNXC:QuickAddStart 2026-08-26-19:19: every other workflow — including a duplicate of this one — + resolves its planning lane from traits instead of returning null. */ + if (workflow.id !== "builtin:coding-ideas") return resolveManualIntakePlanningColumn(workflow); const columns = visibleColumns(workflow); /* DELIBERATE-LITERAL — these are ONE NAMED BUILTIN's own declared ids, not a lifecycle guard.