FN-8498: add Quick Add Start menu

Add gesture-only Quick Add Start promotion for eligible hold-based workflows.

- Open a Start menu with right-click or touch/pen long-press while preserving ordinary Save and Enter behavior.
- Validate and snapshot workflow routing before creating, then move matching tasks through host callbacks.
- Cover workflow guards, promotion outcomes, and Board/List callback wiring.

Files changed: .changeset/fn-8498-quick-add-start-menu.md         |   7 ++
 docs/dashboard-guide.md                            |   4 +
 packages/dashboard/app/components/Column.tsx       |   1 +
 packages/dashboard/app/components/ListView.tsx     |   1 +
 .../dashboard/app/components/QuickEntryBox.css     |  30 +++++
 .../dashboard/app/components/QuickEntryBox.tsx     | 115 ++++++++++++++++-
 .../app/components/__tests__/Column.test.tsx       |  10 +-
 .../app/components/__tests__/ListView.test.tsx     |  12 ++
 .../components/__tests__/QuickEntryBox.test.tsx    | 137 +++++++++++++++++++++
 .../app/utils/__tests__/quickAddStart.test.ts      |  38 ++++++
 packages/dashboard/app/utils/quickAddStart.ts      |  51 ++++++++
 11 files changed, 400 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-8498

Fusion-Task-Lineage: 30d8e7d4-59f1-4c4f-9224-1a9d8c27141b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-22 16:31:24 -07:00
parent e24fb37c8b
commit da616e1cf9
11 changed files with 400 additions and 6 deletions

View File

@@ -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.

View File

@@ -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. --> 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 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.
<!-- FNXC:QuickAddStart 2026-07-22-16:10: Quick Add Start is intentionally a gesture-only enhancement: ordinary Save and Enter remain 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 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 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. 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.

View File

@@ -848,6 +848,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
{canCreateInColumn && ( {canCreateInColumn && (
<QuickEntryBox <QuickEntryBox
onCreate={handleQuickCreate} onCreate={handleQuickCreate}
onMoveTask={onMoveTask}
addToast={addToast} addToast={addToast}
tasks={allTasks ?? []} tasks={allTasks ?? []}
availableModels={availableModels} availableModels={availableModels}

View File

@@ -2592,6 +2592,7 @@ export function ListView({
<div className="list-quick-entry-above-table"> <div className="list-quick-entry-above-table">
<QuickEntryBox <QuickEntryBox
onCreate={handleListQuickCreate} onCreate={handleListQuickCreate}
onMoveTask={onMoveTask}
addToast={addToast} addToast={addToast}
tasks={tasks} tasks={tasks}
availableModels={availableModels} availableModels={availableModels}

View File

@@ -989,3 +989,33 @@ FN-7682 — the tokenized-CSS test forbids raw px in the workflow-selector rules
} }
/*
FNXC:QuickAddStart 2026-07-22-16:10:
The long-press/right-click Start menu is portaled and viewport-clamped by its trigger
calculation, preserving Save as the primary short-click action on desktop and mobile.
*/
.quick-entry-start-menu {
z-index: var(--z-dropdown);
min-width: calc(var(--space-4xl) * 2);
padding: var(--space-xs);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.quick-entry-start-menu button {
width: 100%;
padding: var(--space-xs) var(--space-sm);
background: transparent;
border: 0;
border-radius: var(--radius-sm);
color: var(--text);
text-align: left;
}
.quick-entry-start-menu button:hover,
.quick-entry-start-menu button:focus-visible {
background: var(--card-hover);
}

View File

@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep, ThinkingLevel } from "@fusion/core"; import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep, ThinkingLevel, ColumnId } from "@fusion/core";
import type { ModelInfo, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition, NodeInfo } from "../api"; import type { ModelInfo, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition, NodeInfo } from "../api";
import { checkDuplicateTasks, fetchModels, fetchSettings, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api"; import { checkDuplicateTasks, fetchModels, fetchSettings, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api";
import { DuplicateWarningModal } from "./DuplicateWarningModal"; import { DuplicateWarningModal } from "./DuplicateWarningModal";
@@ -19,6 +19,7 @@ import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
import { WorkflowIcon } from "./WorkflowIcon"; import { WorkflowIcon } from "./WorkflowIcon";
import { PendingImagePreviews } from "./PendingImagePreviews"; import { PendingImagePreviews } from "./PendingImagePreviews";
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator"; import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart";
const STORAGE_KEY = "kb-quick-entry-text"; const STORAGE_KEY = "kb-quick-entry-text";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
@@ -30,6 +31,8 @@ interface PendingImage {
interface QuickEntryBoxProps { interface QuickEntryBoxProps {
onCreate?: (input: CreateTaskInput) => Promise<Task | void>; onCreate?: (input: CreateTaskInput) => Promise<Task | void>;
/** Host-owned state-updating move path; Quick Add never calls the raw API. */
onMoveTask?: (taskId: string, column: ColumnId) => Promise<unknown>;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
tasks?: Task[]; tasks?: Task[];
availableModels?: ModelInfo[]; availableModels?: ModelInfo[];
@@ -135,7 +138,7 @@ function hasMeaningfulNodeChoice(nodes: NodeInfo[]): boolean {
return nodes.length > 1 || nodes.some((node) => node.type !== "local"); 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 { t } = useTranslation("app");
const [description, setDescription] = useState(() => { const [description, setDescription] = useState(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -153,6 +156,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const touchButtonRef = useRef<HTMLButtonElement | null>(null); const touchButtonRef = useRef<HTMLButtonElement | null>(null);
const saveButtonRef = useRef<HTMLButtonElement | null>(null);
const startIntentRef = useRef<ValidatedQuickAddWorkflow | null>(null);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | 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 justResetRef = useRef(false);
const previousProjectIdRef = useRef(projectId); const previousProjectIdRef = useRef(projectId);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]); const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
@@ -345,6 +355,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const quickEntryWorkflowLabel = selectedQuickEntryWorkflow?.name ?? t("tasks.workflow", "Workflow"); const quickEntryWorkflowLabel = selectedQuickEntryWorkflow?.name ?? t("tasks.workflow", "Workflow");
const selectedQuickEntryWorkflowIcon = selectedQuickEntryWorkflow?.icon; const selectedQuickEntryWorkflowIcon = selectedQuickEntryWorkflow?.icon;
const selectedWorkflowForCreate = workflowId === undefined ? undefined : quickEntryWorkflowId; 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(() => { useEffect(() => {
const parentChanged = previousWorkflowDefaultRef.current.workflowId !== workflowId const parentChanged = previousWorkflowDefaultRef.current.workflowId !== workflowId
@@ -744,6 +764,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
} }
const originalDescription = description; const originalDescription = description;
const startWorkflow = startIntentRef.current;
setDescription(""); setDescription("");
try { try {
/* /*
@@ -752,7 +773,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
*/ */
const createdTask = await onCreate({ const createdTask = await onCreate({
description: trimmed, description: trimmed,
...(selectedWorkflowForCreate !== undefined ? { workflowId: selectedWorkflowForCreate } : {}), ...(startWorkflow ? { workflowId: startWorkflow.id } : selectedWorkflowForCreate !== undefined ? { workflowId: selectedWorkflowForCreate } : {}),
dependencies: dependencies.length ? dependencies : undefined, dependencies: dependencies.length ? dependencies : undefined,
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: selectedPresetId, modelPresetId: selectedPresetId,
@@ -781,7 +802,28 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
nodeId: effectiveNodeId, nodeId: effectiveNodeId,
acknowledgedDuplicates: overrides?.acknowledgedDuplicates, 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[] = []; const failures: string[] = [];
for (const pendingImage of pendingImages) { for (const pendingImage of pendingImages) {
try { try {
@@ -800,11 +842,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setDescription(originalDescription); setDescription(originalDescription);
addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error"); addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error");
} finally { } finally {
startIntentRef.current = null;
submitInFlightRef.current = false; submitInFlightRef.current = false;
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [ }, [
onCreate, onCreate,
onMoveTask,
description, description,
dependencies, dependencies,
selectedWorkflowForCreate, selectedWorkflowForCreate,
@@ -831,6 +875,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
projectId, projectId,
addToast, addToast,
resetForm, resetForm,
t,
]); ]);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
@@ -896,6 +941,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}, [description, duplicateMatches, submitCreateTask]); }, [description, duplicateMatches, submitCreateTask]);
const handleDuplicateCancel = useCallback(() => { 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); setDuplicateMatches(null);
submitInFlightRef.current = false; submitInFlightRef.current = false;
setIsSubmitting(false); setIsSubmitting(false);
@@ -1610,11 +1661,52 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
resetForm(); resetForm();
}, [description, onSubtaskBreakdown, selectedWorkflowForCreate, addToast, 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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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(() => { const handleSaveClick = useCallback(() => {
// Save button now creates the task (same as Enter key) if (suppressSaveClickRef.current) {
suppressSaveClickRef.current = false;
return;
}
handleSubmit(); handleSubmit();
}, [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) => const truncate = (s: string, len: number) =>
s.length > len ? s.slice(0, len) + "…" : s; s.length > len ? s.slice(0, len) + "…" : s;
@@ -2379,17 +2471,30 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
</button> </button>
<button <button
ref={saveButtonRef}
type="button" type="button"
className="btn btn-task-create btn-sm" className="btn btn-task-create btn-sm"
onClick={handleSaveClick} onClick={handleSaveClick}
onContextMenu={(event) => { if (canOpenQuickAddStartMenu) { event.preventDefault(); openStartMenu(); } }}
onPointerDown={handleSavePointerDown}
onPointerMove={handleSavePointerMove}
onPointerLeave={clearLongPress}
onPointerUp={clearLongPress}
onPointerCancel={clearLongPress}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim() || isSubmitting} disabled={!description.trim() || isSubmitting}
aria-haspopup={canOpenQuickAddStartMenu ? "menu" : undefined}
data-testid="quick-entry-save" data-testid="quick-entry-save"
title={t("tasks.createTaskTitle", "Create task")} title={t("tasks.createTaskTitle", "Create task")}
> >
<Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} /> <Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("tasks.save", "Save")} {t("tasks.save", "Save")}
</button> </button>
{showStartMenu && canOpenQuickAddStartMenu && portalRoot && startMenuPosition && createPortal(
<div className="quick-entry-start-menu" role="menu" data-testid="quick-entry-save-start" style={{ position: "fixed", top: startMenuPosition.top, left: startMenuPosition.left }}>
<button type="button" role="menuitem" onClick={handleStartClick}>{t("tasks.start", "Start")}</button>
</div>, portalRoot,
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -34,7 +34,7 @@ vi.mock("../WorktreeGroup", () => ({
), ),
})); }));
vi.mock("../QuickEntryBox", () => ({ 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<unknown> }) => (
<div <div
data-testid="quick-entry-box" data-testid="quick-entry-box"
data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])}
@@ -44,6 +44,7 @@ vi.mock("../QuickEntryBox", () => ({
data-auto-expand={autoExpand === false ? "false" : "true"} data-auto-expand={autoExpand === false ? "false" : "true"}
> >
<button type="button" onClick={() => onCreate?.({ description: "Quick task" })}>create</button> <button type="button" onClick={() => onCreate?.({ description: "Quick task" })}>create</button>
<button type="button" data-testid="quick-entry-move" onClick={() => void onMoveTask?.("FN-created", "todo")}>move</button>
</div> </div>
), ),
})); }));
@@ -652,6 +653,13 @@ describe("Column QuickEntryBox", () => {
expect(quickEntry.getAttribute("data-auto-expand")).toBe("false"); 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(<Column {...defaultProps} tasks={[]} onQuickCreate={vi.fn()} onMoveTask={onMoveTask} />);
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 () => { it("preserves selected built-in workflow id when quick-creating in workflow mode", async () => {
const onQuickCreate = vi.fn().mockResolvedValue({}); const onQuickCreate = vi.fn().mockResolvedValue({});
render( render(

View File

@@ -55,6 +55,7 @@ vi.mock("../QuickEntryBox", () => ({
workflowId, workflowId,
workflowOptions, workflowOptions,
defaultWorkflowId, defaultWorkflowId,
onMoveTask,
}: { }: {
onCreate?: (input: { description: string; workflowId?: string | null }) => Promise<unknown>; onCreate?: (input: { description: string; workflowId?: string | null }) => Promise<unknown>;
addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void; addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void;
@@ -63,6 +64,7 @@ vi.mock("../QuickEntryBox", () => ({
workflowId?: string | null; workflowId?: string | null;
workflowOptions?: { id: string; name: string }[]; workflowOptions?: { id: string; name: string }[];
defaultWorkflowId?: string | null; defaultWorkflowId?: string | null;
onMoveTask?: (id: string, column: string) => Promise<unknown>;
}) => { }) => {
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null | undefined>( const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null | undefined>(
@@ -145,6 +147,9 @@ vi.mock("../QuickEntryBox", () => ({
<button type="button" data-testid="quick-entry-save" onClick={() => void submit()}> <button type="button" data-testid="quick-entry-save" onClick={() => void submit()}>
Save Save
</button> </button>
<button type="button" data-testid="quick-entry-move" onClick={() => void onMoveTask?.("FN-created", "todo")}>
Move
</button>
</div> </div>
{modelMenuOpen ? ( {modelMenuOpen ? (
<div data-testid="model-nested-menu"> <div data-testid="model-nested-menu">
@@ -3670,6 +3675,13 @@ describe("ListView Quick Entry", () => {
expect(tableContainer?.contains(quickEntry)).toBe(true); 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 () => { it("shows model selector control when QuickEntryBox is expanded", async () => {
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined); const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
renderListView({ onQuickCreate: mockOnQuickCreate }); renderListView({ onQuickCreate: mockOnQuickCreate });

View File

@@ -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(<QuickEntryBox onCreate={onCreate} onMoveTask={onMoveTask} addToast={vi.fn()} projectId={TEST_PROJECT_ID} tasks={mockTasks} availableModels={MOCK_MODELS} workflowId={ideasWorkflow.id} workflowOptions={[{ ...ideasWorkflow, name: "Refreshed", columns: [{ id: "ideas", name: "Ideas", flags: { hold: true } }, { id: "refreshed-target", name: "Refreshed target", flags: {} }] }]} />);
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(<QuickEntryBox onCreate={onCreate} onMoveTask={onMoveTask} addToast={vi.fn()} workflowId="builtin:coding-ideas" workflowOptions={[{ ...ideasWorkflow, columns: [] }]} />);
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"));
});
});
}); });

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { resolveQuickAddStartTargetColumn, validateQuickAddStartWorkflow, workflowSupportsQuickAddStart } from "../quickAddStart";
const workflow = (overrides: Record<string, unknown> = {}) => ({
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();
});
});

View File

@@ -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<BoardWorkflowDefinition>;
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<string>();
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;
}