From 475bb2d6411cf1b0341c6ab14cb0786c348d7d1c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 17:25:54 -0700 Subject: [PATCH] FN-8637: restrict Quick Add Start to manual-intake workflows Restrict Quick Add Start eligibility to verified manual intake lanes. - Require the server-derived manualIntake flag instead of hold alone. - Preserve Coding Ideas routing while hiding Start for Coding's merged planning lane. - Cover desktop and mobile eligibility behavior and document the updated rule. - Add a patch changeset for the corrected workflow gating. Files changed: .changeset/fn-8637-quick-add-start-manual-intake.md | 7 ++++ docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/QuickEntryBox.tsx | 21 ++++++------ packages/dashboard/app/components/__tests__/Column.test.tsx | 20 ++++++++--- packages/dashboard/app/components/__tests__/ListView.test.tsx | 40 ++++++++++++++++++---- packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx | 21 +++++++++--- packages/dashboard/app/utils/__tests__/quickAddStart.test.ts | 38 ++++++++++++++++++-- packages/dashboard/app/utils/quickAddStart.ts | 9 ++++- 8 files changed, 128 insertions(+), 30 deletions(-) Fusion-Task-Id: FN-8637 Fusion-Task-Lineage: 9652d7d8-f954-49e8-9a76-a2421654baae Co-authored-by: Fusion (runfusion.ai) --- .../fn-8637-quick-add-start-manual-intake.md | 7 ++++ docs/dashboard-guide.md | 2 +- .../app/components/QuickEntryBox.tsx | 21 +++++----- .../app/components/__tests__/Column.test.tsx | 20 +++++++--- .../components/__tests__/ListView.test.tsx | 40 ++++++++++++++++--- .../__tests__/QuickEntryBox.test.tsx | 21 ++++++++-- .../app/utils/__tests__/quickAddStart.test.ts | 38 ++++++++++++++++-- packages/dashboard/app/utils/quickAddStart.ts | 9 ++++- 8 files changed, 128 insertions(+), 30 deletions(-) create mode 100644 .changeset/fn-8637-quick-add-start-manual-intake.md diff --git a/.changeset/fn-8637-quick-add-start-manual-intake.md b/.changeset/fn-8637-quick-add-start-manual-intake.md new file mode 100644 index 0000000000..2d1672d006 --- /dev/null +++ b/.changeset/fn-8637-quick-add-start-manual-intake.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show Quick Add Start only for workflows with a manual waiting intake lane. +category: fix +dev: Uses the server-derived manualIntake workflow-column flag instead of hold alone. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a2d4fdbf11..cef1866022 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -634,7 +634,7 @@ Quick Add and Inline Create model selection include Plan, Executor, Reviewer, an -Quick Add shows a visible **Start** button in its action row — next to Models/Agent, beside the right-aligned Save — only when the exact selected workflow has complete runtime metadata: a real non-sentinel id, nonempty ordered columns with unique nonblank ids, and an object `flags` value on every column. It is eligible only for validated `builtin:coding-ideas` or a validated workflow whose first visible column is a hold. Start snapshots that exact definition and id before duplicate confirmation and submits it unchanged; later selection or metadata refreshes cannot alter routing. For Coding (Ideas), Start proves that visible ordered metadata places a non-intake, non-complete **Todo** after **Ideas**, then includes Todo in the original Board/List create request—there is no follow-up move. Missing, hidden, malformed, reordered, or ambiguous metadata renders no Start button at all, so Save stays the only create affordance there. With an empty description Start is visible but disabled. Ordinary Save and Enter still create in Ideas. Other eligible hold workflows retain their matching-returned-task promotion through the host Board/List move path only to the first later visible working column, skipping intake, hold, and complete columns; no forward target also remains create-only. +Quick Add shows a visible **Start** button in its action row — next to Models/Agent, beside the right-aligned Save — only when the exact selected workflow has complete runtime metadata: a real non-sentinel id, nonempty ordered columns with unique nonblank ids, and an object `flags` value on every column. It is eligible only for validated `builtin:coding-ideas` or a validated workflow whose first visible column is a manual/waiting intake (`manualIntake`); a hold alone is not enough because auto-triaging Planning lanes can also hold cards. Start snapshots that exact definition and id before duplicate confirmation and submits it unchanged; later selection or metadata refreshes cannot alter routing. For Coding (Ideas), Start proves that visible ordered metadata places a non-intake, non-complete **Todo** after **Ideas**, then includes Todo in the original Board/List create request—there is no follow-up move. Missing, hidden, malformed, reordered, or ambiguous metadata renders no Start button at all, so Save stays the only create affordance there. With an empty description Start is visible but disabled. Ordinary Save and Enter still create in Ideas. Other eligible manual-intake workflows retain their matching-returned-task promotion through the host Board/List move path only to the first later visible working column, skipping intake, hold, and complete columns; no forward target also remains create-only. Quick Add's paperclip accepts supported photos and files: PNG, JPEG, GIF, WebP, MP4, WebM, QuickTime video, plain text, Markdown, JSON, YAML, TOML, CSV, and XML. Select files, paste them into the Quick Add input, or drag them onto the box; pending attachments upload to the newly created task sequentially. Image attachments show compact previews that open in a movable, resizable window (a full-screen sheet on mobile); file attachments show an accessible filename and remove action without an image-open control. Unsupported selections are ignored, and if one upload fails after task creation, the task remains created while Quick Add reports the filenames that need retrying. The same bottom action row places the GitHub tracking override beside the paperclip; Priority is an icon-only control whose glyph changes by selected level (down arrow for low, flag for normal, up arrow for high, alert for urgent) and is color-coded by urgency (low blue/info, normal muted, high amber/warning, urgent red/error), and Fast is an icon-only lightning control. These icon-only controls keep accessible labels and the same create-payload behavior as the previous text chips. diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index cbfb99bbbe..1b309f7bec 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -372,13 +372,13 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai const validatedStartWorkflow = useMemo(() => validateQuickAddStartWorkflow(selectedQuickEntryWorkflow), [selectedQuickEntryWorkflow]); const startInitialColumn = validatedStartWorkflow ? resolveQuickAddStartInitialColumn(validatedStartWorkflow) : null; /* - FNXC:QuickAddStart 2026-07-24-11:20: + FNXC:QuickAddStart 2026-07-31-23:51: Start is a VISIBLE button in the quick-add action row for eligible workflows only, replacing the hidden - long-press/right-click Save menu that operators could not discover. Eligibility is unchanged - (`workflowSupportsQuickAddStart`: Coding (Ideas), or any workflow whose first visible lane is a hold/"waiting" - column) and a provable target is still required (`startInitialColumn` for the create-time column override, or - `onMoveTask` for the follow-up move). Workflows without a waiting lane render no Start button at all — Save - stays the single create affordance there. + long-press/right-click Save menu that operators could not discover. Eligibility is `workflowSupportsQuickAddStart`: + Coding (Ideas), or any workflow whose first visible lane is a server-derived manual-intake/"waiting" column. + A provable target is still required (`startInitialColumn` for the create-time column override, or `onMoveTask` + for the follow-up move). Workflows without a waiting lane render no Start button at all — Save stays the single + create affordance there. */ const canQuickAddStart = Boolean(validatedStartWorkflow && workflowSupportsQuickAddStart(validatedStartWorkflow) && (startInitialColumn || onMoveTask)); const canQuickAddStartNow = canQuickAddStart && Boolean(description.trim()) && !isSubmitting; @@ -2154,12 +2154,13 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai )} {/* - FNXC:QuickAddStart 2026-07-24-11:20: + FNXC:QuickAddStart 2026-07-31-23:51: Start renders as the last chip in the options group so it wraps onto the same line as Models/Agent and reads as an alternate create action beside Save (which stays right-aligned in the primary group). It is - present ONLY for hold-first/"waiting"-column workflows — most workflows show no Start chip. With an - empty description it stays visible but DISABLED (matching Save) so the affordance does not appear and - vanish as the operator types; the whole action row still unmounts while a create is in flight. + present ONLY for manual-intake/"waiting"-first workflows — `hold` alone is not eligibility because the + merged auto-triaging Planning lane also holds cards. With an empty description it stays visible but + DISABLED (matching Save) so the affordance does not appear and vanish as the operator types; the whole + action row still unmounts while a create is in flight. */} {canQuickAddStart && ( - + {showStart && } - ), + ); + }, })); vi.mock("lucide-react", () => ({ Link: () => null, @@ -842,9 +846,9 @@ describe("Column QuickEntryBox", () => { it("preserves the explicit Coding Ideas Start column in workflow mode", async () => { const onQuickCreate = vi.fn().mockResolvedValue({}); - render(); + render(); - fireEvent.click(screen.getByRole("button", { name: "start" })); + fireEvent.click(screen.getByTestId("quick-entry-start")); await waitFor(() => expect(onQuickCreate).toHaveBeenCalledWith({ description: "Started task", @@ -853,6 +857,12 @@ describe("Column QuickEntryBox", () => { })); }); + it("does not expose a Quick Add Start control for Coding's merged intake/hold lane", () => { + render(); + + expect(screen.queryByTestId("quick-entry-start")).toBeNull(); + }); + it("preserves selected built-in workflow id when quick-creating in workflow mode", async () => { const onQuickCreate = vi.fn().mockResolvedValue({}); render( diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index c2b7bb4ef0..e30d213f18 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -63,7 +63,7 @@ vi.mock("../QuickEntryBox", () => ({ onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; workflowId?: string | null; - workflowOptions?: { id: string; name: string }[]; + workflowOptions?: { id: string; name: string; columns?: Array<{ flags?: { manualIntake?: boolean } }> }[]; defaultWorkflowId?: string | null; onMoveTask?: (id: string, column: string) => Promise; }) => { @@ -92,6 +92,11 @@ vi.mock("../QuickEntryBox", () => ({ } }; + const selectedWorkflow = workflowOptions?.find((option) => option.id === selectedWorkflowId); + const showStart = selectedWorkflowId === "builtin:coding-ideas" + || defaultWorkflowId === "builtin:coding-ideas" + || selectedWorkflow?.columns?.[0]?.flags?.manualIntake === true; + const handoff = (callback?: (description: string, workflowId?: string | null) => void) => { const description = value.trim(); if (!description || !callback) return; @@ -148,9 +153,11 @@ vi.mock("../QuickEntryBox", () => ({ - + {showStart && ( + + )} @@ -4081,14 +4088,14 @@ describe("ListView Quick Entry", () => { id: "builtin:coding-ideas", name: "Coding (Ideas)", columns: [ - { id: "ideas", name: "Ideas", flags: { intake: true } }, + { id: "ideas", name: "Ideas", flags: { intake: true, hold: true, manualIntake: true } }, { id: "todo", name: "Todo", flags: { hold: true } }, ], }], taskWorkflowIds: {}, }); renderListView({ onQuickCreate }); - await screen.findByTestId("quick-entry-box"); + await waitFor(() => expect(screen.getByTestId("quick-entry-workflow-props")).toHaveAttribute("data-default-workflow-id", "builtin:coding-ideas")); fireEvent.click(screen.getByTestId("quick-entry-start")); await waitFor(() => expect(onQuickCreate).toHaveBeenCalledWith(expect.objectContaining({ @@ -4098,6 +4105,27 @@ describe("ListView Quick Entry", () => { }))); }); + it.each([ + ["desktop", mockDesktopViewport], + ["mobile", mockMobileViewport], + ])("does not expose Quick Add Start for Coding's merged intake/hold lane on %s", async (_label, mockViewport) => { + mockViewport(); + vi.mocked(fetchBoardWorkflows).mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [{ + id: "builtin:coding", + name: "Coding", + columns: [{ id: "planning", name: "Planning", flags: { intake: true, hold: true } }], + }], + taskWorkflowIds: {}, + }); + renderListView({ onQuickCreate: vi.fn() }); + await waitFor(() => expect(screen.getByTestId("quick-entry-workflow-props")).toHaveAttribute("data-default-workflow-id", "builtin:coding")); + + expect(screen.queryByTestId("quick-entry-start")).toBeNull(); + }); + it("wires QuickEntry Start moves through the list host callback", async () => { const onMoveTask = vi.fn().mockResolvedValue(createMockTask({ id: "FN-created", column: "todo" })); renderListView({ onQuickCreate: vi.fn(), onMoveTask }); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index e72b3b298a..66cbcd6f69 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -5562,7 +5562,7 @@ describe("QuickEntryBox", () => { id: "custom:waiting", name: "Waiting first", columns: [ - { id: "waiting", name: "Waiting", flags: { hold: true } }, + { id: "waiting", name: "Waiting", flags: { intake: true, hold: true, manualIntake: true } }, { id: "working", name: "Working", flags: {} }, { id: "done", name: "Done", flags: { complete: true } }, ], @@ -5645,13 +5645,26 @@ describe("QuickEntryBox", () => { expect(onMoveTask).not.toHaveBeenCalled(); }); - it("renders no Start button or shell for unrelated or malformed workflow metadata and keeps Save create-only", async () => { - const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-plain", column: "ideas", workflowId: "custom" }); + it.each([ + ["desktop", mockDesktopViewport], + ["mobile", mockMobileViewport], + ])("renders no Start button or shell for Coding and malformed workflow metadata on %s", async (_label, mockViewport) => { + mockViewport(); + const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-plain", column: "planning", workflowId: "builtin:coding" }); const onMoveTask = vi.fn(); - const { rerender } = renderQuickEntryBox({ onCreate, onMoveTask, workflowId: "custom", workflowOptions: [{ ...ideasWorkflow, id: "custom", columns: [{ id: "ideas", name: "Ideas", flags: { intake: true } }, ...ideasWorkflow.columns.slice(1)] }] }); + const codingWorkflow = { + id: "builtin:coding", + name: "Coding", + columns: [ + { id: "planning", name: "Planning", flags: { intake: true, hold: true } }, + { id: "todo", name: "Todo", flags: {} }, + ], + }; + const { rerender } = renderQuickEntryBox({ onCreate, onMoveTask, workflowId: codingWorkflow.id, workflowOptions: [codingWorkflow] }); enterDescription(); expect(screen.queryByTestId("quick-entry-save-start")).toBeNull(); expect(screen.queryByRole("button", { name: "Start" })).toBeNull(); + expect(screen.queryByTitle("Create and start the task")).toBeNull(); clickSave(); await waitFor(() => expect(onCreate).toHaveBeenCalled()); expect(onMoveTask).not.toHaveBeenCalled(); diff --git a/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts b/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts index 99be740375..3738acbd1b 100644 --- a/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts +++ b/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts @@ -13,9 +13,41 @@ const workflow = (overrides: Record = {}) => ({ }); describe("quick add Start workflow guards", () => { - it("requires complete runtime metadata before builtin or hold eligibility", () => { - expect(workflowSupportsQuickAddStart(validateQuickAddStartWorkflow(workflow({ id: "builtin:coding-ideas" })))).toBe(true); - expect(workflowSupportsQuickAddStart(validateQuickAddStartWorkflow(workflow()))).toBe(true); + it("limits resolved built-ins to Coding Ideas", () => { + const resolvedBuiltinFirstColumns = [ + ["builtin:coding-ideas", { intake: true, hold: true, manualIntake: true }, true], + ["builtin:coding", { intake: true, hold: true }, false], + ["builtin:quick-fix", { intake: true, hold: true }, false], + ["builtin:stepwise-coding", { intake: true, hold: true }, false], + ["builtin:review-heavy", { intake: true, hold: true }, false], + ["builtin:design", { intake: true, hold: true }, false], + ["builtin:compound-engineering", { intake: true, hold: true }, false], + ["builtin:marketing", { intake: true, hold: true }, false], + ["builtin:pr-workflow", { intake: true, hold: true }, false], + ] as const; + + for (const [id, flags, expected] of resolvedBuiltinFirstColumns) { + expect(workflowSupportsQuickAddStart(validateQuickAddStartWorkflow(workflow({ + id, + columns: [{ id: "planning", flags }, { id: "done", flags: { complete: true } }], + })))).toBe(expected); + } + }); + + it("requires a first visible manual intake and complete runtime metadata", () => { + expect(workflowSupportsQuickAddStart(validateQuickAddStartWorkflow(workflow()))).toBe(false); + expect(workflowSupportsQuickAddStart(validateQuickAddStartWorkflow(workflow({ columns: [ + { id: "waiting", flags: { intake: true, hold: true, manualIntake: true } }, + { id: "todo", flags: {} }, + ] })))).toBe(true); + expect(workflowSupportsQuickAddStart(validateQuickAddStartWorkflow(workflow({ columns: [ + { id: "archived", flags: { archived: true, manualIntake: true } }, + { id: "waiting", flags: { manualIntake: true } }, + ] })))).toBe(true); + expect(workflowSupportsQuickAddStart(validateQuickAddStartWorkflow(workflow({ columns: [ + { id: "hidden", flags: { hiddenFromBoard: true, manualIntake: true } }, + { id: "planning", flags: { intake: true, hold: true } }, + ] })))).toBe(false); expect(validateQuickAddStartWorkflow(workflow({ id: "__all_workflows__" }))).toBeNull(); expect(validateQuickAddStartWorkflow(workflow({ columns: [] }))).toBeNull(); expect(validateQuickAddStartWorkflow(workflow({ columns: [{ id: "", flags: {} }] }))).toBeNull(); diff --git a/packages/dashboard/app/utils/quickAddStart.ts b/packages/dashboard/app/utils/quickAddStart.ts index 8bca8a9426..13f18d8ab2 100644 --- a/packages/dashboard/app/utils/quickAddStart.ts +++ b/packages/dashboard/app/utils/quickAddStart.ts @@ -27,10 +27,17 @@ function visibleColumns(workflow: ValidatedQuickAddWorkflow) { return workflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard); } +/* + * FNXC:QuickAddStart 2026-07-31-23:51: + * Start is reserved for a workflow's first visible manual/waiting intake lane. `hold` alone is + * insufficient because the canonical merged Planning column carries both `intake` and `hold` while + * auto-triaging. Mirror TaskCard.showStartAction's server-derived `manualIntake` fact so absent + * older payload metadata fails closed without exposing an unusable Quick Add action. + */ export function workflowSupportsQuickAddStart(workflow: ValidatedQuickAddWorkflow | null): boolean { if (!workflow) return false; if (workflow.id === "builtin:coding-ideas") return true; - return visibleColumns(workflow)[0]?.flags.hold === true; + return visibleColumns(workflow)[0]?.flags.manualIntake === true; } /**