From 6c8884e67b0bcb3596a6331f20c76fcb462b2dd1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 08:52:51 -0700 Subject: [PATCH] FN-7276: add quick-add workflow selection Add workflow-aware quick task creation across Board and List quick-add surfaces. - Add a quick-add workflow picker that filters out the aggregate workflow sentinel. - Route create, planning, subtask, and optional-step flows through the selected real workflow. - Resolve workflow-specific intake columns for Board/List quick creates and cover behavior with component tests. - Add a minor changeset for the published CLI package. Files changed: .changeset/fn-7276-quick-add-workflow-selector.md | 7 + packages/dashboard/app/components/Board.tsx | 43 +++++- packages/dashboard/app/components/Column.tsx | 12 +- packages/dashboard/app/components/ListView.tsx | 20 ++- .../dashboard/app/components/QuickEntryBox.css | 51 +++++++ .../dashboard/app/components/QuickEntryBox.tsx | 169 +++++++++++++++++++-- .../app/components/__tests__/Board.test.tsx | 42 ++++- .../app/components/__tests__/ListView.test.tsx | 67 +++++++- .../components/__tests__/QuickEntryBox.test.tsx | 73 +++++++++ 9 files changed, 453 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-7276 Fusion-Task-Lineage: 422f0359-c71f-4610-8fdb-39d7a21a2a52 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7276-quick-add-workflow-selector.md | 7 + packages/dashboard/app/components/Board.tsx | 43 ++++- packages/dashboard/app/components/Column.tsx | 12 +- .../dashboard/app/components/ListView.tsx | 20 ++- .../app/components/QuickEntryBox.css | 51 ++++++ .../app/components/QuickEntryBox.tsx | 169 ++++++++++++++++-- .../app/components/__tests__/Board.test.tsx | 42 ++++- .../components/__tests__/ListView.test.tsx | 67 ++++++- .../__tests__/QuickEntryBox.test.tsx | 73 ++++++++ 9 files changed, 453 insertions(+), 31 deletions(-) create mode 100644 .changeset/fn-7276-quick-add-workflow-selector.md diff --git a/.changeset/fn-7276-quick-add-workflow-selector.md b/.changeset/fn-7276-quick-add-workflow-selector.md new file mode 100644 index 0000000000..4a1e319261 --- /dev/null +++ b/.changeset/fn-7276-quick-add-workflow-selector.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a quick-add workflow selector for Board and List task creation. +category: feature +dev: The selector drives save, planning, subtask handoff, and workflow-step loading without submitting the aggregate workflow sentinel. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index d8c7a4732c..ef638a7ee3 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -436,6 +436,18 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o : boardWorkflows.defaultWorkflowId; }, [boardWorkflows, knownWorkflowIds]); + const resolveWorkflowQuickCreateTarget = useCallback((targetWorkflowId: string, preferredColumnId?: string | null): ColumnId | undefined => { + if (targetWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) return undefined; + 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); + const preferredColumn = preferredColumnId ? visibleColumns.find((column) => column.id === preferredColumnId) : undefined; + const column = preferredColumn + ?? visibleColumns.find((candidate) => candidate.flags.intake) + ?? visibleColumns[0]; + return column?.id as ColumnId | undefined; + }, [boardWorkflows]); + const selectedWorkflowTasks = useMemo(() => { if (!workflowMode || !boardWorkflows || !selectedWorkflow) return []; return tasks.filter((task) => getEffectiveTaskWorkflowId(task) === selectedWorkflow.id); @@ -464,14 +476,22 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o */ const handleWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => { if (!onQuickCreate || !selectedWorkflow) return undefined; - const created = await onQuickCreate(input); + const targetWorkflowId = typeof input.workflowId === "string" && input.workflowId !== ALL_WORKFLOWS_BOARD_VIEW_ID + ? input.workflowId + : selectedWorkflow.id; + const targetColumn = resolveWorkflowQuickCreateTarget(targetWorkflowId, input.column); + const created = await onQuickCreate({ + ...input, + ...(targetColumn ? { column: targetColumn } : {}), + workflowId: targetWorkflowId, + }); if (created?.id) { - const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? selectedWorkflow.id; + const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? targetWorkflowId; applyOptimisticTaskWorkflow(created.id, createdWorkflowId); refreshBoardWorkflows(); } return created; - }, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows, selectedWorkflow]); + }, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows, resolveWorkflowQuickCreateTarget, selectedWorkflow]); /** * FNXC:WorkflowBoard 2026-06-29-23:58: @@ -479,15 +499,22 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o */ const handleAggregateWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => { if (!onQuickCreate) return undefined; - const created = await onQuickCreate(input); - const targetWorkflowId = typeof input.workflowId === "string" ? input.workflowId : undefined; + const targetWorkflowId = typeof input.workflowId === "string" && input.workflowId !== ALL_WORKFLOWS_BOARD_VIEW_ID + ? input.workflowId + : (boardWorkflows?.workflows.find((workflow) => workflow.id === boardWorkflows.defaultWorkflowId)?.id ?? boardWorkflows?.workflows[0]?.id); + const targetColumn = targetWorkflowId ? resolveWorkflowQuickCreateTarget(targetWorkflowId, input.column) : undefined; + const created = await onQuickCreate({ + ...input, + ...(targetColumn ? { column: targetColumn } : {}), + ...(targetWorkflowId ? { workflowId: targetWorkflowId } : {}), + }); if (created?.id && targetWorkflowId) { const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? targetWorkflowId; applyOptimisticTaskWorkflow(created.id, createdWorkflowId); refreshBoardWorkflows(); } return created; - }, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows]); + }, [applyOptimisticTaskWorkflow, boardWorkflows, onQuickCreate, refreshBoardWorkflows, resolveWorkflowQuickCreateTarget]); const selectedWorkflowArchivedColumn = useMemo(() => { if (!selectedWorkflow) return null; @@ -812,7 +839,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} mergeStrategy={mergeStrategy} - {...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} + {...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, workflowOptions, defaultWorkflowId: boardWorkflows?.defaultWorkflowId ?? null, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} {...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} @@ -890,7 +917,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} mergeStrategy={mergeStrategy} - {...(isCreateColumn ? { onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} + {...(isCreateColumn ? { workflowOptions, defaultWorkflowId: selectedWorkflow.id, onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} {...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 42e4c7b7df..ba38bb26ca 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -13,7 +13,7 @@ import { groupByWorktree } from "../utils/worktreeGrouping"; import type { ToastType } from "../hooks/useToast"; import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu"; import { ChevronDown, ChevronUp, MoreVertical } from "lucide-react"; -import type { ModelInfo, BoardWorkflowColumnFlags } from "../api"; +import type { BoardWorkflowDefinition, ModelInfo, BoardWorkflowColumnFlags } from "../api"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; import type { DoneColumnSortMode } from "./taskSorting"; @@ -167,6 +167,10 @@ interface ColumnProps { workflowMode?: boolean; /** Workflow id for column-aware task creation in workflow mode. */ workflowId?: string; + /** Real workflow choices for the quick-add selector in workflow mode. */ + workflowOptions?: BoardWorkflowDefinition[]; + /** Default workflow target for quick-add when the parent view is aggregate or stale. */ + defaultWorkflowId?: string | null; /** Display name for this column, from the workflow definition. */ columnDisplayName?: string; /** Resolved trait flags for this column (workflow mode). */ @@ -190,7 +194,7 @@ interface ColumnProps { getDraggingTaskId?: () => string | null; } -function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { +function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { const { t } = useTranslation("app"); // Anchor the board.rejection.* catalog keys for the i18next extractor (it // scopes `t` to the useTranslation binding, so the shared translateRejection @@ -421,7 +425,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree return onQuickCreate({ ...input, column, - ...(workflowId ? { workflowId } : {}), + ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : (workflowId ? { workflowId } : {})), }); } return onQuickCreate(input); @@ -769,6 +773,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree onPlanningMode={onPlanningMode} onSubtaskBreakdown={onSubtaskBreakdown} workflowId={workflowMode ? workflowId : undefined} + workflowOptions={workflowMode ? workflowOptions : undefined} + defaultWorkflowId={workflowMode ? defaultWorkflowId : undefined} projectId={projectId} autoExpand={false} favoriteProviders={favoriteProviders} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 4ca988cffc..3e20ebed99 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -682,13 +682,25 @@ export function ListView({ }); }, [projectId]); + const resolveListQuickCreateTarget = useCallback((targetWorkflowId: string, preferredColumnId?: string | null): ColumnId | undefined => { + 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); + const preferredColumn = preferredColumnId ? visibleColumns.find((column) => column.id === preferredColumnId) : undefined; + const column = preferredColumn + ?? visibleColumns.find((candidate) => candidate.flags.intake) + ?? visibleColumns[0]; + return column?.id as ColumnId | undefined; + }, [boardWorkflows]); + const handleListQuickCreate = useCallback(async (input: TaskCreateInput) => { const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error")); if (workflowMode && selectedWorkflow && createTargetColumn) { - const workflowId = input.workflowId ?? selectedWorkflow.id; + const workflowId = typeof input.workflowId === "string" ? input.workflowId : selectedWorkflow.id; + const targetColumn = resolveListQuickCreateTarget(workflowId, input.column) ?? createTargetColumn; const created = await create({ ...input, - column: input.column ?? createTargetColumn, + column: targetColumn, workflowId, }); if (created?.id) { @@ -699,7 +711,7 @@ export function ListView({ return created; } return create(input); - }, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, onQuickCreate, refreshBoardWorkflows, selectedWorkflow, t, workflowMode]); + }, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, onQuickCreate, refreshBoardWorkflows, resolveListQuickCreateTarget, selectedWorkflow, t, workflowMode]); /* FNXC:ListWorkflowSelection 2026-06-29-00:00: @@ -2350,6 +2362,8 @@ export function ListView({ onPlanningMode={onPlanningMode} onSubtaskBreakdown={onSubtaskBreakdown} workflowId={listQuickEntryWorkflowId} + workflowOptions={workflowMode ? workflowOptions : undefined} + defaultWorkflowId={workflowMode ? selectedWorkflow?.id ?? boardWorkflows?.defaultWorkflowId ?? null : undefined} projectId={projectId} autoExpand={false} defaultExpanded={false} diff --git a/packages/dashboard/app/components/QuickEntryBox.css b/packages/dashboard/app/components/QuickEntryBox.css index 15e3ea6cbf..b112fa8fbf 100644 --- a/packages/dashboard/app/components/QuickEntryBox.css +++ b/packages/dashboard/app/components/QuickEntryBox.css @@ -135,6 +135,57 @@ The global `.description-with-refine textarea { padding-right: 70px }` (styles.c flex: 1 1 auto; } +/* +FNXC:QuickAddWorkflow 2026-06-30-00:00: +Quick-add workflow targeting sits in the action row as a compact existing-button dropdown. Use tokenized sizing/colors so the selector wraps with Save/Plan controls on narrow Board and List surfaces without introducing a separate visual hierarchy. +*/ +.quick-entry-workflow-wrap { + position: relative; + display: inline-flex; + min-width: 0; +} + +.quick-entry-workflow-trigger { + max-width: calc(var(--space-xl) * 8); +} + +.quick-entry-workflow-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quick-entry-workflow-menu { + position: absolute; + inset-block-start: calc(100% + var(--space-xs)); + inset-inline-start: 0; + min-width: min(calc(var(--space-xl) * 10), calc(100vw - var(--space-lg))); + max-width: calc(100vw - var(--space-lg)); + max-height: min(calc(var(--space-xl) * 10), calc(100vh - var(--space-xl) * 2)); + overflow-y: auto; + z-index: 1000; +} + +.quick-entry-workflow-option { + width: 100%; + border: 0; + background: transparent; + color: inherit; + text-align: left; +} + +@media (max-width: 768px) { + .quick-entry-workflow-wrap, + .quick-entry-workflow-trigger { + max-width: 100%; + } + + .quick-entry-workflow-menu { + min-width: min(calc(var(--space-xl) * 9), calc(100vw - var(--space-lg))); + } +} + .quick-entry-model-wrap { position: relative; } diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 6394480d3f..e27bfbb6b6 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -1,11 +1,11 @@ import "./QuickEntryBox.css"; -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import type { ToastType } from "../hooks/useToast"; import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core"; import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep } from "@fusion/core"; -import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch } from "../api"; +import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition } from "../api"; import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api"; import { DuplicateWarningModal } from "./DuplicateWarningModal"; import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react"; @@ -40,6 +40,10 @@ interface QuickEntryBoxProps { onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; /** Selected workflow lane for AI-assisted create actions. Omit in legacy board mode to preserve project-default inheritance. */ workflowId?: string | null; + /** Real workflows available to the quick-add workflow selector. Board-only aggregate sentinels must be filtered before rendering/submission. */ + workflowOptions?: BoardWorkflowDefinition[]; + /** Project/default workflow id used when the parent view is aggregate or stale. */ + defaultWorkflowId?: string | null; /** Optional project context for API calls */ projectId?: string; /** @@ -101,7 +105,25 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri }; } -export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, workflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) { +function getRealWorkflowOptions(workflowOptions: BoardWorkflowDefinition[] | undefined): BoardWorkflowDefinition[] { + return (workflowOptions ?? []).filter((workflow) => workflow.id !== "__all_workflows__"); +} + +function resolveQuickAddWorkflowId( + parentWorkflowId: string | null | undefined, + defaultWorkflowId: string | null | undefined, + workflowOptions: BoardWorkflowDefinition[], +): string | null | undefined { + if (parentWorkflowId === undefined) return undefined; + if (parentWorkflowId === null) return null; + if (workflowOptions.length === 0) return parentWorkflowId === "__all_workflows__" ? null : parentWorkflowId; + const validIds = new Set(workflowOptions.map((workflow) => workflow.id)); + if (validIds.has(parentWorkflowId)) return parentWorkflowId; + if (defaultWorkflowId && validIds.has(defaultWorkflowId)) return defaultWorkflowId; + return workflowOptions[0]?.id ?? null; +} + +export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, 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") { @@ -162,6 +184,17 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, const [portalRoot] = useState(() => typeof document !== "undefined" ? document.body : null, ); + const realWorkflowOptions = useMemo(() => getRealWorkflowOptions(workflowOptions), [workflowOptions]); + const workflowPickerRef = useRef(null); + const previousWorkflowDefaultRef = useRef<{ workflowId: string | null | undefined; defaultWorkflowId: string | null | undefined }>({ workflowId, defaultWorkflowId }); + const [showWorkflowPicker, setShowWorkflowPicker] = useState(false); + /* + FNXC:QuickAddWorkflow 2026-06-30-00:00: + Quick-add needs an independent real workflow target because the main Board selector can be on the aggregate "All workflows" read view. Resolve only against real workflow ids and keep the aggregate sentinel out of task create, Plan, Subtask, and optional-step requests. + */ + const [quickEntryWorkflowId, setQuickEntryWorkflowId] = useState(() => ( + resolveQuickAddWorkflowId(workflowId, defaultWorkflowId, getRealWorkflowOptions(workflowOptions)) + )); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); const [loadedModels, setLoadedModels] = useState(availableModels ?? []); @@ -259,10 +292,38 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, FNXC:WorkflowOptionalSteps 2026-06-26-05:10: Resolution MUST mirror the executor/store (explicit workflowId → project default → `builtin:coding`). The earlier `?? null` tail hid `builtin:coding`'s optional steps (browser-verification, code-review) whenever no project default workflow was configured, so operators never saw the toggles even though the unselected task runs `builtin:coding` (FN-7039). Fall back to `builtin:coding` once settings have loaded; stay `null` while settings are still loading so we don't fetch the wrong workflow then refetch. */ + const selectedQuickEntryWorkflow = typeof quickEntryWorkflowId === "string" + ? realWorkflowOptions.find((option) => option.id === quickEntryWorkflowId) + : undefined; + const quickEntryWorkflowNameCounts = useMemo(() => { + const counts = new Map(); + for (const option of realWorkflowOptions) { + counts.set(option.name, (counts.get(option.name) ?? 0) + 1); + } + return counts; + }, [realWorkflowOptions]); + const showWorkflowSelector = workflowId !== undefined && realWorkflowOptions.length >= 2 && quickEntryWorkflowId !== null; + const quickEntryWorkflowLabel = selectedQuickEntryWorkflow?.name ?? t("tasks.workflow", "Workflow"); + const selectedWorkflowForCreate = workflowId === undefined ? undefined : quickEntryWorkflowId; + + useEffect(() => { + const parentChanged = previousWorkflowDefaultRef.current.workflowId !== workflowId + || previousWorkflowDefaultRef.current.defaultWorkflowId !== defaultWorkflowId; + previousWorkflowDefaultRef.current = { workflowId, defaultWorkflowId }; + setQuickEntryWorkflowId((current) => { + const resolved = resolveQuickAddWorkflowId(workflowId, defaultWorkflowId, realWorkflowOptions); + if (parentChanged) return resolved; + if (typeof current === "string" && realWorkflowOptions.some((option) => option.id === current)) { + return current; + } + return resolved; + }); + }, [defaultWorkflowId, realWorkflowOptions, workflowId]); + const effectiveOptionalWorkflowId = - workflowId === null + selectedWorkflowForCreate === null ? null - : (workflowId ?? settings?.defaultWorkflowId ?? (settings ? "builtin:coding" : null)); + : (selectedWorkflowForCreate ?? settings?.defaultWorkflowId ?? (settings ? "builtin:coding" : null)); useEffect(() => { let cancelled = false; @@ -480,6 +541,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, return () => document.removeEventListener("mousedown", handleClickOutside); }, [showPriorityPicker]); + useEffect(() => { + if (!showWorkflowPicker) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + if (workflowPickerRef.current?.contains(target)) return; + setShowWorkflowPicker(false); + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showWorkflowPicker]); + const resetForm = useCallback(() => { pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); @@ -566,6 +640,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, const createdTask = await onCreate({ description: trimmed, column: "triage", + ...(selectedWorkflowForCreate !== undefined ? { workflowId: selectedWorkflowForCreate } : {}), dependencies: dependencies.length ? dependencies : undefined, ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), modelPresetId: selectedPresetId, @@ -612,6 +687,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onCreate, description, dependencies, + selectedWorkflowForCreate, selectedAgentId, selectedPresetId, hasExecutorOverride, @@ -752,6 +828,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, setPriorityPickerPosition(null); return; } + if (showWorkflowPicker) { + setShowWorkflowPicker(false); + return; + } if (showAgentPicker) { setShowAgentPicker(false); setAgentPickerPosition(null); @@ -786,6 +866,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, activeModelSubmenu, isRefineMenuOpen, showPriorityPicker, + showWorkflowPicker, projectId, setIsDisclosureExpanded, duplicateMatches, @@ -1379,8 +1460,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error"); return; } - if (workflowId !== undefined) { - onPlanningMode?.(trimmed, workflowId); + if (selectedWorkflowForCreate !== undefined) { + onPlanningMode?.(trimmed, selectedWorkflowForCreate); } else { onPlanningMode?.(trimmed); } @@ -1388,7 +1469,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, FNXC:QuickAddPlanningPreserve 2026-06-22-00:00: Opening planning mode must preserve the quick-add description and scoped draft so exiting planning without creating tasks restores the user's text. The draft is cleared only by planning-completion handlers. */ - }, [description, onPlanningMode, workflowId, addToast, t]); + }, [description, onPlanningMode, selectedWorkflowForCreate, addToast, t]); const handleSubtaskClick = useCallback(() => { const trimmed = description.trim(); @@ -1396,14 +1477,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error"); return; } - if (workflowId !== undefined) { - onSubtaskBreakdown?.(trimmed, workflowId); + if (selectedWorkflowForCreate !== undefined) { + onSubtaskBreakdown?.(trimmed, selectedWorkflowForCreate); } else { onSubtaskBreakdown?.(trimmed); } // Clear the form after triggering subtask breakdown resetForm(); - }, [description, onSubtaskBreakdown, workflowId, addToast, resetForm]); + }, [description, onSubtaskBreakdown, selectedWorkflowForCreate, addToast, resetForm]); const handleSaveClick = useCallback(() => { // Save button now creates the task (same as Enter key) @@ -1571,6 +1652,72 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, touchButtonRef.current = null; }} > + {showWorkflowSelector && ( +
+ + {showWorkflowPicker && ( +
+
{t("tasks.quickEntryWorkflowHeader", "Create in workflow")}
+ {realWorkflowOptions.map((option) => { + const duplicateName = (quickEntryWorkflowNameCounts.get(option.name) ?? 0) > 1; + const optionLabel = duplicateName + ? t("tasks.quickEntryWorkflowDuplicateLabel", "{{name}} ({{id}})", { name: option.name, id: option.id }) + : option.name; + return ( + + ); + })} +
+ )} +
+ )} + + ) : null} {tasks.map((task) => (
{task.title ?? task.description ?? task.id} @@ -1406,6 +1415,37 @@ describe("Board", () => { expect(screen.queryByTestId("workflow-switcher-edit-__all_workflows__")).toBeNull(); }); + + it("passes workflow options and the selected workflow default to per-workflow quick-add", async () => { + enableFlag({ "FN-1": CUSTOM_WORKFLOW.id }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); + renderBoard({ tasks: [mkTask({ id: "FN-1", column: "intake" })] }); + + await selectWorkflow(CUSTOM_WORKFLOW.id); + + const intakeColumn = screen.getByTestId("column-intake"); + expect(intakeColumn).toHaveAttribute("data-default-workflow-id", CUSTOM_WORKFLOW.id); + expect(JSON.parse(intakeColumn.getAttribute("data-workflow-options") || "[]")).toEqual(["builtin:coding", "wf-custom"]); + }); + + it("defaults All workflows quick-add to the default workflow and resolves selected workflow columns", async () => { + const onQuickCreate = vi.fn().mockResolvedValue({ id: "FN-new", workflowId: "wf-custom" }); + enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); + renderBoard({ onQuickCreate }); + + await selectWorkflow("__all_workflows__"); + + const defaultCreateColumn = screen.getByTestId("column-triage"); + expect(defaultCreateColumn).toHaveAttribute("data-workflow-id", "builtin:coding"); + expect(defaultCreateColumn).toHaveAttribute("data-default-workflow-id", "builtin:coding"); + fireEvent.click(screen.getByTestId("mock-quick-create-triage")); + + await waitFor(() => expect(onQuickCreate).toHaveBeenCalledWith(expect.objectContaining({ + workflowId: "wf-custom", + column: "intake", + }))); + expect(onQuickCreate).not.toHaveBeenCalledWith(expect.objectContaining({ workflowId: "__all_workflows__" })); + }); + it("falls stale and missing task workflow ids back to the default workflow", async () => { enableFlag( { "FN-default": "builtin:coding", "FN-stale": "wf-deleted", "FN-custom": "wf-custom" }, diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index af1839a104..fc96c655b5 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, it, expect, vi } from "vitest"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ListView } from "../ListView"; @@ -52,22 +52,36 @@ vi.mock("../QuickEntryBox", () => ({ onPlanningMode, onSubtaskBreakdown, workflowId, + workflowOptions, + defaultWorkflowId, }: { - onCreate?: (input: { description: string }) => Promise; + onCreate?: (input: { description: string; workflowId?: string | null }) => Promise; addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void; onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; workflowId?: string | null; + workflowOptions?: { id: string; name: string }[]; + defaultWorkflowId?: string | null; }) => { const [value, setValue] = useState(""); + const [selectedWorkflowId, setSelectedWorkflowId] = useState( + workflowId ?? defaultWorkflowId ?? workflowOptions?.[0]?.id, + ); const [expanded, setExpanded] = useState(false); const [modelMenuOpen, setModelMenuOpen] = useState(false); + useEffect(() => { + setSelectedWorkflowId(workflowId ?? defaultWorkflowId ?? workflowOptions?.[0]?.id); + }, [defaultWorkflowId, workflowId, workflowOptions]); + const submit = async () => { const description = value.trim(); if (!description || !onCreate) return; try { - await onCreate({ description }); + await onCreate({ + description, + ...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}), + }); setValue(""); } catch (err) { addToast(err instanceof Error ? err.message : "Failed to create task", "error"); @@ -77,8 +91,8 @@ vi.mock("../QuickEntryBox", () => ({ const handoff = (callback?: (description: string, workflowId?: string | null) => void) => { const description = value.trim(); if (!description || !callback) return; - if (workflowId !== undefined) { - callback(description, workflowId); + if (selectedWorkflowId !== undefined) { + callback(description, selectedWorkflowId); return; } callback(description); @@ -121,6 +135,12 @@ vi.mock("../QuickEntryBox", () => ({ + {workflowOptions && workflowOptions.length > 1 ? ( + + ) : null} + option.id))} /> @@ -3070,6 +3090,43 @@ describe("ListView Quick Entry", () => { }); }); + it("passes workflow options to quick-add and submits the changed selector workflow", async () => { + const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined); + vi.mocked(fetchBoardWorkflows).mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:default", + workflows: [ + { + id: "builtin:default", + name: "Default", + columns: [{ id: "triage", name: "Triage", flags: { intake: true } }], + }, + { + id: "wf-custom", + name: "Custom", + columns: [{ id: "backlog", name: "Backlog", flags: { intake: true } }], + }, + ], + taskWorkflowIds: {}, + }); + renderListView({ onQuickCreate: mockOnQuickCreate }); + + await waitFor(() => expect(screen.getByTestId("quick-entry-workflow-props")).toHaveAttribute("data-workflow-id", "builtin:default")); + expect(screen.getByTestId("quick-entry-workflow-props")).toHaveAttribute("data-default-workflow-id", "builtin:default"); + expect(JSON.parse(screen.getByTestId("quick-entry-workflow-props").getAttribute("data-workflow-options") || "[]")).toEqual(["builtin:default", "wf-custom"]); + + fireEvent.click(screen.getByTestId("quick-entry-toggle")); + fireEvent.click(screen.getByTestId("quick-entry-workflow-option-wf-custom")); + fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Create on changed list workflow" } }); + fireEvent.click(screen.getByTestId("quick-entry-save")); + + await waitFor(() => expect(mockOnQuickCreate).toHaveBeenCalledWith(expect.objectContaining({ + description: "Create on changed list workflow", + workflowId: "wf-custom", + column: "backlog", + }))); + }); + it("passes the selected workflow id to list quick-entry Plan and Subtask handoffs", async () => { const onPlanningMode = vi.fn(); const onSubtaskBreakdown = vi.fn(); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index da9245519c..cbfbae7811 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -1614,6 +1614,79 @@ describe("QuickEntryBox", () => { innerWidthSpy.mockRestore(); }); + + describe("quick-add workflow selector", () => { + const workflowOptions = [ + { id: "wf-default", name: "Coding", columns: [] }, + { id: "wf-review", name: "Review", columns: [] }, + { id: "wf-review-copy", name: "Review", columns: [] }, + ]; + + it("defaults to the provided workflow, changes selection, and passes it to Save/Plan/Subtask", async () => { + vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([]); + const onCreate = vi.fn().mockResolvedValue(CREATED_TASK); + const onPlanningMode = vi.fn(); + const onSubtaskBreakdown = vi.fn(); + renderQuickEntryBox({ + onCreate, + onPlanningMode, + onSubtaskBreakdown, + workflowId: "wf-review", + defaultWorkflowId: "wf-default", + workflowOptions, + }); + + expect(screen.getByTestId("quick-entry-workflow-trigger")).toHaveTextContent("Review"); + fireEvent.click(screen.getByTestId("quick-entry-workflow-trigger")); + expect(screen.getByLabelText("Review (wf-review-copy)")).toBeTruthy(); + fireEvent.click(screen.getByTestId("quick-entry-workflow-option-wf-default")); + expect(screen.getByTestId("quick-entry-workflow-trigger")).toHaveTextContent("Coding"); + + fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Create in selected workflow" } }); + clickSave(); + await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: "wf-default" }))); + + fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Plan in selected workflow" } }); + fireEvent.click(screen.getByTestId("plan-button")); + expect(onPlanningMode).toHaveBeenCalledWith("Plan in selected workflow", "wf-default"); + + fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Subtask in selected workflow" } }); + fireEvent.click(screen.getByTestId("subtask-button")); + expect(onSubtaskBreakdown).toHaveBeenCalledWith("Subtask in selected workflow", "wf-default"); + }); + + it("repairs stale parent workflow ids to the default workflow and refetches optional steps when changed", async () => { + vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([]); + renderQuickEntryBox({ + workflowId: "__all_workflows__", + defaultWorkflowId: "wf-default", + workflowOptions, + }); + + await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-default", TEST_PROJECT_ID)); + expect(screen.getByTestId("quick-entry-workflow-trigger")).toHaveTextContent("Coding"); + + fireEvent.click(screen.getByTestId("quick-entry-workflow-trigger")); + fireEvent.click(screen.getByTestId("quick-entry-workflow-option-wf-review")); + await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-review", TEST_PROJECT_ID)); + expect(fetchWorkflowOptionalSteps).not.toHaveBeenCalledWith("__all_workflows__", TEST_PROJECT_ID); + }); + + it("hides the selector without real workflow choices and leaves no shell", () => { + renderQuickEntryBox({ workflowId: "wf-default", workflowOptions: [{ id: "wf-default", name: "Coding", columns: [] }] }); + + expect(screen.queryByTestId("quick-entry-workflow-trigger")).toBeNull(); + expect(screen.getByTestId("quick-entry-actions").querySelector(".quick-entry-workflow-wrap")).toBeNull(); + }); + + it("uses tokenized responsive CSS for the workflow selector", () => { + const selectorRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-menu"); + expect(selectorRule).toContain("var(--space-"); + expect(selectorRule).not.toMatch(/#[0-9a-f]{3,8}|rgb\(/i); + expect(QUICK_ENTRY_BOX_CSS).toMatch(/@media \(max-width: 768px\) \{[\s\S]*?quick-entry-workflow/); + }); + }); + describe("optional workflow steps", () => { const DEFAULT_ON_STEP = { templateId: "browser-verification",