diff --git a/.changeset/fn-8498-quick-add-start-menu.md b/.changeset/fn-8498-quick-add-start-menu.md new file mode 100644 index 0000000000..592948c7c5 --- /dev/null +++ b/.changeset/fn-8498-quick-add-start-menu.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a gesture-only Quick Add Start action for eligible workflows. +category: feature +dev: Validates submitted workflow metadata and promotes only matching created tasks forward. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d6a093b6ab..a20c02a93b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -572,6 +572,10 @@ 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 image attachments use the paperclip icon button in the action row. Supported image files (`png`, `jpeg`, `gif`, `webp`) can be selected from that control, pasted into the Quick Add input, or dragged onto the Quick Add box; all three paths show compact pending previews before task creation and upload the images to the created task afterward. Select a pending preview to inspect the full image in a movable, resizable window (a full-screen sheet on mobile); close it with Escape or the close control to return to the preview. 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. Quick entry, inline quick-create, and the full **New Task** dialog all check for similar active tasks before creating. When possible duplicates exist, the warning lists each match by task description (falling back to title, then “No description”) and lets you open an existing task, cancel, or create anyway with the duplicates acknowledged. diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 93bf334486..46685c2225 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -848,6 +848,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree {canCreateInColumn && ( Promise; + /** Host-owned state-updating move path; Quick Add never calls the raw API. */ + onMoveTask?: (taskId: string, column: ColumnId) => Promise; addToast: (message: string, type?: ToastType) => void; tasks?: Task[]; availableModels?: ModelInfo[]; @@ -135,7 +138,7 @@ function hasMeaningfulNodeChoice(nodes: NodeInfo[]): boolean { return nodes.length > 1 || nodes.some((node) => node.type !== "local"); } -export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onSubtaskBreakdown, workflowId, workflowOptions, defaultWorkflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) { +export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], availableModels, onSubtaskBreakdown, workflowId, workflowOptions, defaultWorkflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) { const { t } = useTranslation("app"); const [description, setDescription] = useState(() => { if (typeof window !== "undefined") { @@ -153,6 +156,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, const textareaRef = useRef(null); const fileInputRef = useRef(null); const touchButtonRef = useRef(null); + const saveButtonRef = useRef(null); + const startIntentRef = useRef(null); + const longPressTimerRef = useRef | null>(null); + const longPressStartRef = useRef<{ x: number; y: number; pointerId: number } | null>(null); + const suppressSaveClickRef = useRef(false); + const [showStartMenu, setShowStartMenu] = useState(false); + const [startMenuPosition, setStartMenuPosition] = useState<{ top: number; left: number } | null>(null); const justResetRef = useRef(false); const previousProjectIdRef = useRef(projectId); const [pendingImages, setPendingImages] = useState([]); @@ -345,6 +355,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, const quickEntryWorkflowLabel = selectedQuickEntryWorkflow?.name ?? t("tasks.workflow", "Workflow"); const selectedQuickEntryWorkflowIcon = selectedQuickEntryWorkflow?.icon; const selectedWorkflowForCreate = workflowId === undefined ? undefined : quickEntryWorkflowId; + const validatedStartWorkflow = useMemo(() => validateQuickAddStartWorkflow(selectedQuickEntryWorkflow), [selectedQuickEntryWorkflow]); + const canQuickAddStart = Boolean(onMoveTask && validatedStartWorkflow && workflowSupportsQuickAddStart(validatedStartWorkflow)); + const canOpenQuickAddStartMenu = canQuickAddStart && Boolean(description.trim()) && !isSubmitting; + + useEffect(() => { + if (!canOpenQuickAddStartMenu) { + setShowStartMenu(false); + setStartMenuPosition(null); + } + }, [canOpenQuickAddStartMenu]); useEffect(() => { const parentChanged = previousWorkflowDefaultRef.current.workflowId !== workflowId @@ -744,6 +764,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, } const originalDescription = description; + const startWorkflow = startIntentRef.current; setDescription(""); try { /* @@ -752,7 +773,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, */ const createdTask = await onCreate({ description: trimmed, - ...(selectedWorkflowForCreate !== undefined ? { workflowId: selectedWorkflowForCreate } : {}), + ...(startWorkflow ? { workflowId: startWorkflow.id } : selectedWorkflowForCreate !== undefined ? { workflowId: selectedWorkflowForCreate } : {}), dependencies: dependencies.length ? dependencies : undefined, ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), modelPresetId: selectedPresetId, @@ -781,7 +802,28 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, nodeId: effectiveNodeId, acknowledgedDuplicates: overrides?.acknowledgedDuplicates, }); - if (createdTask && pendingImages.length > 0) { + /* + 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. + */ + if (startWorkflow && 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" + && (createdTask as Task & { workflowId?: string }).workflowId === startWorkflow.id) { + const target = resolveQuickAddStartTargetColumn(startWorkflow, createdTask.column); + if (target && onMoveTask) { + try { + await onMoveTask(createdTask.id, target as ColumnId); + addToast(t("tasks.startedPlanning", "Started planning {{taskId}}", { taskId: createdTask.id }), "success"); + } catch (moveError) { + addToast(getErrorMessage(moveError) || t("tasks.createFailed", "Failed to create task"), "error"); + } + } + } + if (createdTask && typeof createdTask.id === "string" && createdTask.id.trim() && pendingImages.length > 0) { const failures: string[] = []; for (const pendingImage of pendingImages) { try { @@ -800,11 +842,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, setDescription(originalDescription); addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error"); } finally { + startIntentRef.current = null; submitInFlightRef.current = false; setIsSubmitting(false); } }, [ onCreate, + onMoveTask, description, dependencies, selectedWorkflowForCreate, @@ -831,6 +875,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, projectId, addToast, resetForm, + t, ]); const handleSubmit = useCallback(async () => { @@ -896,6 +941,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, }, [description, duplicateMatches, submitCreateTask]); const handleDuplicateCancel = useCallback(() => { + /* + FNXC:QuickAddStart 2026-07-22-16:10: + Cancelling duplicate confirmation discards the saved Start intent. A later ordinary Save + must remain create-only rather than reusing a promotion snapshot from the cancelled action. + */ + startIntentRef.current = null; setDuplicateMatches(null); submitInFlightRef.current = false; setIsSubmitting(false); @@ -1610,11 +1661,52 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, resetForm(); }, [description, onSubtaskBreakdown, selectedWorkflowForCreate, addToast, resetForm]); + const openStartMenu = useCallback(() => { + if (!canOpenQuickAddStartMenu) return; + const rect = saveButtonRef.current?.getBoundingClientRect(); + if (!rect) return; + setStartMenuPosition({ top: Math.min(rect.bottom + 4, window.innerHeight - 40), left: Math.max(8, Math.min(rect.right - 120, window.innerWidth - 128)) }); + setShowStartMenu(true); + }, [canOpenQuickAddStartMenu]); + + const clearLongPress = useCallback(() => { + if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + longPressStartRef.current = null; + }, []); + + const handleSavePointerDown = useCallback((event: React.PointerEvent) => { + if (!canQuickAddStart || (event.pointerType !== "touch" && event.pointerType !== "pen")) return; + clearLongPress(); + longPressStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId }; + longPressTimerRef.current = setTimeout(() => { + suppressSaveClickRef.current = true; + clearLongPress(); + openStartMenu(); + }, 550); + }, [canQuickAddStart, clearLongPress, openStartMenu]); + + const handleSavePointerMove = useCallback((event: React.PointerEvent) => { + const start = longPressStartRef.current; + if (start && start.pointerId === event.pointerId && (Math.abs(start.x - event.clientX) > 10 || Math.abs(start.y - event.clientY) > 10)) clearLongPress(); + }, [clearLongPress]); + const handleSaveClick = useCallback(() => { - // Save button now creates the task (same as Enter key) + if (suppressSaveClickRef.current) { + suppressSaveClickRef.current = false; + return; + } handleSubmit(); }, [handleSubmit]); + const handleStartClick = useCallback(() => { + if (!canOpenQuickAddStartMenu || !validatedStartWorkflow) return; + startIntentRef.current = validatedStartWorkflow; + setShowStartMenu(false); + setStartMenuPosition(null); + handleSubmit(); + }, [canOpenQuickAddStartMenu, handleSubmit, validatedStartWorkflow]); + const truncate = (s: string, len: number) => s.length > len ? s.slice(0, len) + "…" : s; @@ -2379,17 +2471,30 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, + {showStartMenu && canOpenQuickAddStartMenu && portalRoot && startMenuPosition && createPortal( +
+ +
, portalRoot, + )} )} diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index a91d719cbc..47e1ce47a4 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 }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; autoExpand?: boolean; onCreate?: (input: { description: string }) => void }) => ( + 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 }) => (
({ data-auto-expand={autoExpand === false ? "false" : "true"} > +
), })); @@ -652,6 +653,13 @@ describe("Column QuickEntryBox", () => { expect(quickEntry.getAttribute("data-auto-expand")).toBe("false"); }); + it("wires QuickEntry Start moves through the host state-updating callback", async () => { + const onMoveTask = vi.fn().mockResolvedValue(makeTask("FN-created")); + render(); + fireEvent.click(screen.getByTestId("quick-entry-move")); + await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-created", "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 21dd81c0a2..72923966a8 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -55,6 +55,7 @@ vi.mock("../QuickEntryBox", () => ({ workflowId, workflowOptions, defaultWorkflowId, + onMoveTask, }: { onCreate?: (input: { description: string; workflowId?: string | null }) => Promise; addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void; @@ -63,6 +64,7 @@ vi.mock("../QuickEntryBox", () => ({ workflowId?: string | null; workflowOptions?: { id: string; name: string }[]; defaultWorkflowId?: string | null; + onMoveTask?: (id: string, column: string) => Promise; }) => { const [value, setValue] = useState(""); const [selectedWorkflowId, setSelectedWorkflowId] = useState( @@ -145,6 +147,9 @@ vi.mock("../QuickEntryBox", () => ({ + {modelMenuOpen ? (
@@ -3670,6 +3675,13 @@ describe("ListView Quick Entry", () => { expect(tableContainer?.contains(quickEntry)).toBe(true); }); + 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 }); + fireEvent.click(screen.getByTestId("quick-entry-move")); + await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-created", "todo")); + }); + it("shows model selector control when QuickEntryBox is expanded", async () => { const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined); renderListView({ onQuickCreate: mockOnQuickCreate }); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index df017245b5..6dca6c5759 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -5430,4 +5430,141 @@ describe("QuickEntryBox", () => { }); }); + describe("Quick Add Start menu", () => { + const ideasWorkflow = { + id: "builtin:coding-ideas", + name: "Coding (Ideas)", + columns: [ + { id: "ideas", name: "Ideas", flags: { hold: true } }, + { id: "todo", name: "Todo", flags: {} }, + { id: "done", name: "Done", flags: { complete: true } }, + ], + }; + + 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 () => { + mockDesktopViewport(); + const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-start", column: "ideas", workflowId: ideasWorkflow.id }); + const onMoveTask = vi.fn().mockResolvedValue({}); + renderQuickEntryBox({ onCreate, onMoveTask, workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] }); + enterDescription(); + + openStartByContextMenu(); + 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")); + }); + + it("opens Start after touch and pen long-press, suppresses the compatibility click, and cancels moved/up/cancelled pointers", async () => { + mockMobileViewport(); + const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-touch", column: "ideas", workflowId: ideasWorkflow.id }); + const onMoveTask = vi.fn().mockResolvedValue({}); + const touchRender = renderQuickEntryBox({ onCreate, onMoveTask, workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] }); + enterDescription(); + const save = screen.getByTestId("quick-entry-save"); + + fireEvent.pointerDown(save, { pointerType: "touch", pointerId: 1, clientX: 20, clientY: 20 }); + await act(async () => { vi.advanceTimersByTime(550); }); + expect(screen.getByTestId("quick-entry-save-start")).toBeInTheDocument(); + 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(save).not.toBeDisabled()); + touchRender.unmount(); + + const { unmount } = renderQuickEntryBox({ onCreate: vi.fn(), onMoveTask: vi.fn(), 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 }); + unmount(); + + const cancelled = renderQuickEntryBox({ onMoveTask: vi.fn(), workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] }); + fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Cancelled" } }); + const cancelledSave = screen.getByTestId("quick-entry-save"); + fireEvent.pointerDown(cancelledSave, { pointerType: "touch", pointerId: 3, clientX: 20, clientY: 20 }); + fireEvent.pointerMove(cancelledSave, { pointerType: "touch", pointerId: 3, clientX: 31, clientY: 20 }); + await act(async () => { vi.advanceTimersByTime(550); }); + expect(screen.queryByTestId("quick-entry-save-start")).toBeNull(); + fireEvent.pointerDown(cancelledSave, { pointerType: "touch", pointerId: 4, clientX: 20, clientY: 20 }); + fireEvent.pointerUp(cancelledSave, { pointerType: "touch", pointerId: 4 }); + fireEvent.pointerCancel(cancelledSave, { pointerType: "touch", pointerId: 4 }); + await act(async () => { vi.advanceTimersByTime(550); }); + expect(screen.queryByTestId("quick-entry-save-start")).toBeNull(); + cancelled.unmount(); + }); + + it("preserves the submitted Start workflow snapshot through duplicate confirmation", async () => { + const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-duplicate", column: "ideas", workflowId: ideasWorkflow.id }); + const onMoveTask = vi.fn().mockResolvedValue({}); + vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([{ id: "FN-existing", title: "Existing", description: "Existing", column: "ideas", score: 0.9 }]); + const { rerender } = renderQuickEntryBox({ onCreate, onMoveTask, workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] }); + enterDescription(); + openStartByContextMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Start" })); + expect(await screen.findByText("Possible duplicates")).toBeInTheDocument(); + + 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")); + }); + + it("has no Start 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" }); + 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)] }] }); + enterDescription(); + expect(screen.getByTestId("quick-entry-save")).not.toHaveAttribute("aria-haspopup"); + openStartByContextMenu(); + expect(screen.queryByTestId("quick-entry-save-start")).toBeNull(); + clickSave(); + await waitFor(() => expect(onCreate).toHaveBeenCalled()); + expect(onMoveTask).not.toHaveBeenCalled(); + + rerender(); + fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Malformed" } }); + 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 () => { + const cases = [ + undefined, + { ...CREATED_TASK, id: "", column: "ideas", workflowId: ideasWorkflow.id }, + { ...CREATED_TASK, id: "FN-no-column", column: "", workflowId: ideasWorkflow.id }, + { ...CREATED_TASK, id: "FN-wrong", column: "ideas", workflowId: "other" }, + { ...CREATED_TASK, id: "FN-last", column: "done", workflowId: ideasWorkflow.id }, + ]; + for (const created of cases) { + const onCreate = vi.fn().mockResolvedValue(created); + const onMoveTask = vi.fn().mockResolvedValue({}); + const { unmount } = renderQuickEntryBox({ onCreate, onMoveTask, workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] }); + enterDescription(); + openStartByContextMenu(); + await screen.findByTestId("quick-entry-save-start"); + fireEvent.click(screen.getByRole("menuitem", { name: "Start" })); + await waitFor(() => expect(onCreate).toHaveBeenCalled()); + expect(onMoveTask).not.toHaveBeenCalled(); + 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 new file mode 100644 index 0000000000..7fd337d5f5 --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/quickAddStart.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { resolveQuickAddStartTargetColumn, validateQuickAddStartWorkflow, workflowSupportsQuickAddStart } from "../quickAddStart"; + +const workflow = (overrides: Record = {}) => ({ + id: "custom", + name: "Custom", + columns: [ + { id: "ideas", name: "Ideas", flags: { hold: true } }, + { id: "todo", name: "Todo", flags: {} }, + { id: "done", name: "Done", flags: { complete: true } }, + ], + ...overrides, +}); + +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); + expect(validateQuickAddStartWorkflow(workflow({ id: "__all_workflows__" }))).toBeNull(); + expect(validateQuickAddStartWorkflow(workflow({ columns: [] }))).toBeNull(); + expect(validateQuickAddStartWorkflow(workflow({ columns: [{ id: "", flags: {} }] }))).toBeNull(); + expect(validateQuickAddStartWorkflow(workflow({ columns: [{ id: "a", flags: {} }, { id: "a", flags: {} }] }))).toBeNull(); + expect(validateQuickAddStartWorkflow(workflow({ columns: [{ id: "a", flags: null }] }))).toBeNull(); + }); + + it("only chooses a later visible working destination", () => { + const valid = validateQuickAddStartWorkflow(workflow({ columns: [ + { id: "ideas", name: "Ideas", flags: { hold: 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!, "todo")).toBeNull(); + expect(resolveQuickAddStartTargetColumn(valid!, "unknown")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/utils/quickAddStart.ts b/packages/dashboard/app/utils/quickAddStart.ts new file mode 100644 index 0000000000..b3b8fb0a38 --- /dev/null +++ b/packages/dashboard/app/utils/quickAddStart.ts @@ -0,0 +1,51 @@ +import type { BoardWorkflowDefinition } from "../api"; + +export type ValidatedQuickAddWorkflow = BoardWorkflowDefinition; + +/** + * FNXC:QuickAddStart 2026-07-22-16:10: + * Start is exposed only after a complete runtime validation, rather than trusting stale + * dashboard metadata. This keeps touch/pen long-press and mouse right-click affordances + * unavailable unless the submitted workflow can prove its ordered routing columns. + */ +export function validateQuickAddStartWorkflow(value: unknown): ValidatedQuickAddWorkflow | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const workflow = value as Partial; + if (typeof workflow.id !== "string" || !workflow.id.trim() || workflow.id === "__all_workflows__") return null; + if (!Array.isArray(workflow.columns) || workflow.columns.length === 0) return null; + const ids = new Set(); + for (const column of workflow.columns) { + if (!column || typeof column !== "object" || Array.isArray(column)) return null; + if (typeof column.id !== "string" || !column.id.trim() || ids.has(column.id)) return null; + if (!column.flags || typeof column.flags !== "object" || Array.isArray(column.flags)) return null; + ids.add(column.id); + } + return workflow as ValidatedQuickAddWorkflow; +} + +function visibleColumns(workflow: ValidatedQuickAddWorkflow) { + return workflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard); +} + +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; +} + +/** + * FNXC:QuickAddStart 2026-07-22-16:10: + * A valid 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. + */ +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; +}