diff --git a/.changeset/fn-8509-coding-ideas-start-todo.md b/.changeset/fn-8509-coding-ideas-start-todo.md new file mode 100644 index 0000000000..377795723b --- /dev/null +++ b/.changeset/fn-8509-coding-ideas-start-todo.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Create Coding Ideas Start tasks directly in Todo. +category: fix +dev: Validates the captured workflow metadata and preserves Todo through Board and List quick-add hosts. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6ad2a32416..c270eb8864 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -572,9 +572,9 @@ Optional workflow steps declared by the active workflow are available from the q FNXC:PriorityColorCoding 2026-07-11-00:00: Priority glyphs share urgency colors across Quick Add, the New Task inline row, and task-card badges: low=info/blue, normal=muted, high=warning/amber, urgent=error/red. --> Quick Add and Inline Create model selection include Plan, Executor, Reviewer, and Merger lanes. Each lane can inherit its default or select a task-specific model; Plan, Reviewer, and Merger also provide independent thinking-level overrides. - + -Quick Add **Save** supports a **Start** menu on touch/pen long-press or mouse right-click 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 promotion. A returned create result that is absent, malformed, missing an id/column/workflow id, or mismatched to the submitted workflow is still a successful create-only result. For a matching result, Start moves 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 **Save** supports a **Start** menu on touch/pen long-press or mouse right-click 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 fails closed to create-only behavior. 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'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/Column.tsx b/packages/dashboard/app/components/Column.tsx index ab577471af..b7a3fdabc2 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -457,9 +457,15 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree (input: TaskCreateInput) => { if (!onQuickCreate) return Promise.resolve(); if (workflowMode) { + /* + FNXC:QuickAddStart 2026-07-22-17:45: + The Quick Add Start intent may carry a workflow-validated initial Todo column. + Preserve that explicit create destination; ordinary Save has no column and continues + to inherit this rendered intake column. + */ return onQuickCreate({ ...input, - column, + column: input.column ?? column, ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : (workflowId ? { workflowId } : {})), }); } diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 21a945239e..c431471a88 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -772,6 +772,11 @@ export function ListView({ const workflow = boardWorkflows?.workflows.find((candidate) => candidate.id === targetWorkflowId); if (!workflow) return undefined; const visibleColumns = workflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard); + /* + FNXC:QuickAddStart 2026-07-22-17:45: + Preserve a Quick Add Start column only when the selected workflow's visible metadata + still validates it. Ordinary Save omits the preference and retains list intake routing. + */ const preferredColumn = preferredColumnId ? visibleColumns.find((column) => column.id === preferredColumnId) : undefined; const column = preferredColumn ?? visibleColumns.find((candidate) => candidate.flags.intake) diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 9bfd76b172..27cd490f47 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -19,7 +19,7 @@ import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown"; import { WorkflowIcon } from "./WorkflowIcon"; import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews"; import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator"; -import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart"; +import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart"; const STORAGE_KEY = "kb-quick-entry-text"; const ALLOWED_TASK_ATTACHMENT_TYPES = new Set([ @@ -372,7 +372,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai const selectedQuickEntryWorkflowIcon = selectedQuickEntryWorkflow?.icon; const selectedWorkflowForCreate = workflowId === undefined ? undefined : quickEntryWorkflowId; const validatedStartWorkflow = useMemo(() => validateQuickAddStartWorkflow(selectedQuickEntryWorkflow), [selectedQuickEntryWorkflow]); - const canQuickAddStart = Boolean(onMoveTask && validatedStartWorkflow && workflowSupportsQuickAddStart(validatedStartWorkflow)); + const startInitialColumn = validatedStartWorkflow ? resolveQuickAddStartInitialColumn(validatedStartWorkflow) : null; + const canQuickAddStart = Boolean(validatedStartWorkflow && workflowSupportsQuickAddStart(validatedStartWorkflow) && (startInitialColumn || onMoveTask)); const canOpenQuickAddStartMenu = canQuickAddStart && Boolean(description.trim()) && !isSubmitting; useEffect(() => { @@ -794,6 +795,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai const originalDescription = description; const startWorkflow = startIntentRef.current; + const startInitialColumn = startWorkflow ? resolveQuickAddStartInitialColumn(startWorkflow) : null; setDescription(""); try { /* @@ -803,6 +805,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai const createdTask = await onCreate({ description: trimmed, ...(startWorkflow ? { workflowId: startWorkflow.id } : selectedWorkflowForCreate !== undefined ? { workflowId: selectedWorkflowForCreate } : {}), + ...(startInitialColumn ? { column: startInitialColumn as ColumnId } : {}), dependencies: dependencies.length ? dependencies : undefined, ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), modelPresetId: selectedPresetId, @@ -832,12 +835,12 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai acknowledgedDuplicates: overrides?.acknowledgedDuplicates, }); /* - FNXC:QuickAddStart 2026-07-22-16:10: - Start snapshots the exact validated workflow before duplicate confirmation. A returned task - must prove its id, column, and matching workflow identity before host promotion; otherwise - creation remains successful and Save/Enter behavior is unchanged. + FNXC:QuickAddStart 2026-07-22-17:45: + Coding (Ideas) Start submits its validated Todo destination in the original create request. + Custom hold workflows retain their returned-task promotion path; unprovable targets stay + create-only, so Save/Enter and malformed metadata never guess a transition. */ - if (startWorkflow && createdTask && typeof createdTask === "object" + if (startWorkflow && !startInitialColumn && createdTask && typeof createdTask === "object" && typeof createdTask.id === "string" && createdTask.id.trim() && typeof createdTask.column === "string" && createdTask.column.trim() && typeof (createdTask as Task & { workflowId?: unknown }).workflowId === "string" diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index e0220e200f..5207509c13 100644 --- a/packages/dashboard/app/components/__tests__/Column.test.tsx +++ b/packages/dashboard/app/components/__tests__/Column.test.tsx @@ -34,7 +34,7 @@ vi.mock("../WorktreeGroup", () => ({ ), })); vi.mock("../QuickEntryBox", () => ({ - QuickEntryBox: ({ favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, autoExpand, onCreate, onMoveTask }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; autoExpand?: boolean; onCreate?: (input: { description: string }) => void; onMoveTask?: (id: string, column: string) => Promise }) => ( + QuickEntryBox: ({ favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, autoExpand, onCreate, onMoveTask }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; autoExpand?: boolean; onCreate?: (input: { description: string; workflowId?: string; column?: string }) => void; onMoveTask?: (id: string, column: string) => Promise }) => (
({ data-auto-expand={autoExpand === false ? "false" : "true"} > +
), @@ -698,6 +699,19 @@ describe("Column QuickEntryBox", () => { await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-created", "todo")); }); + it("preserves the explicit Coding Ideas Start column in workflow mode", async () => { + const onQuickCreate = vi.fn().mockResolvedValue({}); + render(); + + fireEvent.click(screen.getByRole("button", { name: "start" })); + + await waitFor(() => expect(onQuickCreate).toHaveBeenCalledWith({ + description: "Started task", + workflowId: "builtin:coding-ideas", + column: "todo", + })); + }); + 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 72923966a8..4734775c74 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -57,7 +57,7 @@ vi.mock("../QuickEntryBox", () => ({ defaultWorkflowId, onMoveTask, }: { - onCreate?: (input: { description: string; workflowId?: string | null }) => Promise; + onCreate?: (input: { description: string; workflowId?: string | null; column?: string }) => Promise; addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void; onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; @@ -147,6 +147,9 @@ vi.mock("../QuickEntryBox", () => ({ + @@ -3675,6 +3678,32 @@ describe("ListView Quick Entry", () => { expect(tableContainer?.contains(quickEntry)).toBe(true); }); + it("preserves the explicit Coding Ideas Start column through the list host", async () => { + const onQuickCreate = vi.fn().mockResolvedValue(createMockTask({ id: "FN-started", column: "todo" })); + vi.mocked(fetchBoardWorkflows).mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding-ideas", + workflows: [{ + id: "builtin:coding-ideas", + name: "Coding (Ideas)", + columns: [ + { id: "ideas", name: "Ideas", flags: { intake: true } }, + { id: "todo", name: "Todo", flags: { hold: true } }, + ], + }], + taskWorkflowIds: {}, + }); + renderListView({ onQuickCreate }); + await screen.findByTestId("quick-entry-box"); + fireEvent.click(screen.getByTestId("quick-entry-start")); + + await waitFor(() => expect(onQuickCreate).toHaveBeenCalledWith(expect.objectContaining({ + description: "Started task", + workflowId: "builtin:coding-ideas", + column: "todo", + }))); + }); + 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 a4d277d394..6bd1308cb1 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -5485,7 +5485,7 @@ describe("QuickEntryBox", () => { const enterDescription = () => fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Start this task" } }); const openStartByContextMenu = () => fireEvent.contextMenu(screen.getByTestId("quick-entry-save")); - it("opens Start from mouse right-click and promotes from the returned created column", async () => { + it("creates Coding Ideas Start in Todo from mouse right-click without a follow-up move", async () => { mockDesktopViewport(); const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-start", column: "ideas", workflowId: ideasWorkflow.id }); const onMoveTask = vi.fn().mockResolvedValue({}); @@ -5496,8 +5496,8 @@ describe("QuickEntryBox", () => { await screen.findByTestId("quick-entry-save-start"); fireEvent.click(screen.getByRole("menuitem", { name: "Start" })); - await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ description: "Start this task", workflowId: ideasWorkflow.id }))); - await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-start", "todo")); + await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ description: "Start this task", workflowId: ideasWorkflow.id, column: "todo" }))); + expect(onMoveTask).not.toHaveBeenCalled(); }); it("opens Start after touch and pen long-press, suppresses the compatibility click, and cancels moved/up/cancelled pointers", async () => { @@ -5514,17 +5514,22 @@ describe("QuickEntryBox", () => { fireEvent.click(save); expect(onCreate).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("menuitem", { name: "Start" })); - await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-touch", "todo")); + await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: ideasWorkflow.id, column: "todo" }))); + expect(onMoveTask).not.toHaveBeenCalled(); await waitFor(() => expect(save).not.toBeDisabled()); touchRender.unmount(); - const { unmount } = renderQuickEntryBox({ onCreate: vi.fn(), onMoveTask: vi.fn(), workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] }); + const penOnCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-pen", column: "todo", workflowId: ideasWorkflow.id }); + const penOnMoveTask = vi.fn(); + const { unmount } = renderQuickEntryBox({ onCreate: penOnCreate, onMoveTask: penOnMoveTask, workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] }); fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Pen task" } }); const penSave = screen.getAllByTestId("quick-entry-save").at(-1)!; fireEvent.pointerDown(penSave, { pointerType: "pen", pointerId: 2, clientX: 20, clientY: 20 }); await act(async () => { vi.advanceTimersByTime(550); }); expect(screen.getByTestId("quick-entry-save-start")).toBeInTheDocument(); - fireEvent.pointerLeave(penSave, { pointerType: "pen", pointerId: 2 }); + fireEvent.click(screen.getByRole("menuitem", { name: "Start" })); + await waitFor(() => expect(penOnCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: ideasWorkflow.id, column: "todo" }))); + expect(penOnMoveTask).not.toHaveBeenCalled(); unmount(); const cancelled = renderQuickEntryBox({ onMoveTask: vi.fn(), workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] }); @@ -5555,8 +5560,8 @@ describe("QuickEntryBox", () => { rerender(); fireEvent.click(screen.getByRole("button", { name: "Create anyway" })); - await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: ideasWorkflow.id }))); - await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-duplicate", "todo")); + await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: ideasWorkflow.id, column: "todo" }))); + expect(onMoveTask).not.toHaveBeenCalled(); }); it("has no Start shell for unrelated or malformed workflow metadata and keeps Save create-only", async () => { @@ -5576,7 +5581,7 @@ describe("QuickEntryBox", () => { expect(screen.getByTestId("quick-entry-save")).not.toHaveAttribute("aria-haspopup"); }); - it("treats void, invalid, mismatched, no-target, and move failures as the documented create-only/error paths", async () => { + it("treats void and invalid create results as create-only paths without a move", async () => { const cases = [ undefined, { ...CREATED_TASK, id: "", column: "ideas", workflowId: ideasWorkflow.id }, @@ -5597,14 +5602,6 @@ describe("QuickEntryBox", () => { unmount(); } - const addToast = vi.fn(); - const onMoveTask = vi.fn().mockRejectedValue(new Error("move failed")); - renderQuickEntryBox({ onCreate: vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-error", column: "ideas", workflowId: ideasWorkflow.id }), onMoveTask, addToast, workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] }); - enterDescription(); - openStartByContextMenu(); - await screen.findByTestId("quick-entry-save-start"); - fireEvent.click(screen.getByRole("menuitem", { name: "Start" })); - await waitFor(() => expect(addToast).toHaveBeenCalledWith("move failed", "error")); }); }); diff --git a/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts b/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts index 7fd337d5f5..99be740375 100644 --- a/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts +++ b/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveQuickAddStartTargetColumn, validateQuickAddStartWorkflow, workflowSupportsQuickAddStart } from "../quickAddStart"; +import { resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, validateQuickAddStartWorkflow, workflowSupportsQuickAddStart } from "../quickAddStart"; const workflow = (overrides: Record = {}) => ({ id: "custom", @@ -23,6 +23,29 @@ describe("quick add Start workflow guards", () => { expect(validateQuickAddStartWorkflow(workflow({ columns: [{ id: "a", flags: null }] }))).toBeNull(); }); + it("derives Todo only from a captured, visible Coding Ideas definition", () => { + const canonical = validateQuickAddStartWorkflow(workflow({ id: "builtin:coding-ideas" })); + expect(canonical).not.toBeNull(); + expect(resolveQuickAddStartInitialColumn(canonical!)).toBe("todo"); + + for (const columns of [ + [{ id: "ideas", flags: { hold: true } }, { id: "todo", flags: { hiddenFromBoard: true } }], + [{ id: "todo", flags: {} }, { id: "ideas", flags: { hold: true } }], + [{ id: "ideas", flags: { hold: true } }, { id: "todo", flags: { intake: true } }], + [{ id: "ideas", flags: { hold: true } }, { id: "todo", flags: { complete: true } }], + ]) { + const invalidTarget = validateQuickAddStartWorkflow(workflow({ id: "builtin:coding-ideas", columns })); + expect(invalidTarget).not.toBeNull(); + expect(resolveQuickAddStartInitialColumn(invalidTarget!)).toBeNull(); + } + + expect(resolveQuickAddStartInitialColumn(validateQuickAddStartWorkflow(workflow())!)).toBeNull(); + expect(validateQuickAddStartWorkflow(workflow({ + id: "builtin:coding-ideas", + columns: [{ id: "ideas", flags: {} }, { id: "todo", flags: {} }, { id: "todo", flags: {} }], + }))).toBeNull(); + }); + it("only chooses a later visible working destination", () => { const valid = validateQuickAddStartWorkflow(workflow({ columns: [ { id: "ideas", name: "Ideas", flags: { hold: true } }, diff --git a/packages/dashboard/app/utils/quickAddStart.ts b/packages/dashboard/app/utils/quickAddStart.ts index b3b8fb0a38..a5dc559424 100644 --- a/packages/dashboard/app/utils/quickAddStart.ts +++ b/packages/dashboard/app/utils/quickAddStart.ts @@ -35,7 +35,7 @@ export function workflowSupportsQuickAddStart(workflow: ValidatedQuickAddWorkflo /** * FNXC:QuickAddStart 2026-07-22-16:10: - * A valid Start promotion uses the returned task's actual column and moves forward only to + * 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. */ @@ -49,3 +49,22 @@ export function resolveQuickAddStartTargetColumn(workflow: ValidatedQuickAddWork } return null; } + +/** + * FNXC:QuickAddStart 2026-07-22-17:45: + * Coding (Ideas) Start must atomically create in Todo, while ordinary Save/Enter still create + * in Ideas. Prove the destination from the captured, ordered visible definition: Ideas must + * precede one non-intake, non-complete Todo lane. Missing, hidden, reordered, or malformed + * metadata fails closed rather than guessing a transition. + */ +export function resolveQuickAddStartInitialColumn(workflow: ValidatedQuickAddWorkflow): string | null { + if (workflow.id !== "builtin:coding-ideas") return null; + const columns = visibleColumns(workflow); + const ideasIndex = columns.findIndex((column) => column.id === "ideas"); + const todoIndex = columns.findIndex((column) => column.id === "todo"); + if (ideasIndex < 0 || todoIndex <= ideasIndex) return null; + + const todo = columns[todoIndex]; + if (!todo || todo.flags.intake || todo.flags.complete) return null; + return todo.id; +}