feat(quick-add): show a visible Start button for waiting-column workflows

Replace the hidden long-press/right-click Start menu on Quick Add's Save with a
visible Start button in the action row, rendered only for workflows whose first
visible lane is a hold column (or Coding (Ideas)). Eligibility, the workflow
snapshot, the create-time column override, and the hold-first follow-up move are
unchanged; ineligible workflows render no Start chip or shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-24 22:32:30 -07:00
parent 1959e7c02c
commit 8156308695
5 changed files with 137 additions and 151 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Quick Add now shows a visible Start button for workflows with a waiting column.
category: feature
dev: Replaces the hidden long-press/right-click Start menu on Save in `QuickEntryBox`; eligibility still comes from `workflowSupportsQuickAddStart` (Coding (Ideas) or a hold-first workflow) and the create-time column override / follow-up move paths are unchanged.

View File

@@ -585,9 +585,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.
<!-- FNXC:QuickAddStart 2026-07-22-17:45: Coding (Ideas) Start is an atomic Todo create, while ordinary Save and Enter remain create-only in Ideas. -->
<!-- FNXC:QuickAddStart 2026-07-22-17:45: Coding (Ideas) Start is an atomic Todo create, while ordinary Save and Enter remain create-only in Ideas.
FNXC:QuickAddStart 2026-07-24-11:20: Start is now a visible action-row button for eligible workflows instead of a hidden long-press/right-click menu on Save; eligibility, snapshotting, and fail-closed routing are unchanged. -->
Quick Add **Save** supports a **Start** menu on touch/pen long-press or mouse right-click only when the exact selected workflow has complete runtime metadata: a real non-sentinel id, nonempty ordered columns with unique nonblank ids, and an object `flags` value on every column. It is eligible only for validated `builtin:coding-ideas` or a validated workflow whose first visible column is a hold. Start snapshots that exact definition and id before duplicate confirmation and submits it unchanged; later selection or metadata refreshes cannot alter routing. For Coding (Ideas), Start proves that visible ordered metadata places a non-intake, non-complete **Todo** after **Ideas**, then includes Todo in the original Board/List create request—there is no follow-up move. Missing, hidden, malformed, reordered, or ambiguous metadata fails closed to create-only behavior. Ordinary Save and Enter still create in Ideas. Other eligible hold workflows retain their matching-returned-task promotion through the host Board/List move path only to the first later visible working column, skipping intake, hold, and complete columns; no forward target also remains create-only.
Quick Add shows a visible **Start** button in its action row — next to Models/Agent, beside the right-aligned Save — only when the exact selected workflow has complete runtime metadata: a real non-sentinel id, nonempty ordered columns with unique nonblank ids, and an object `flags` value on every column. It is eligible only for validated `builtin:coding-ideas` or a validated workflow whose first visible column is a hold. Start snapshots that exact definition and id before duplicate confirmation and submits it unchanged; later selection or metadata refreshes cannot alter routing. For Coding (Ideas), Start proves that visible ordered metadata places a non-intake, non-complete **Todo** after **Ideas**, then includes Todo in the original Board/List create request—there is no follow-up move. Missing, hidden, malformed, reordered, or ambiguous metadata renders no Start button at all, so Save stays the only create affordance there. With an empty description Start is visible but disabled. Ordinary Save and Enter still create in Ideas. Other eligible hold workflows retain their matching-returned-task promotion through the host Board/List move path only to the first later visible working column, skipping intake, hold, and complete columns; no forward target also remains create-only.
Quick Add's paperclip accepts supported photos and files: PNG, JPEG, GIF, WebP, MP4, WebM, QuickTime video, plain text, Markdown, JSON, YAML, TOML, CSV, and XML. Select files, paste them into the Quick Add input, or drag them onto the box; pending attachments upload to the newly created task sequentially. Image attachments show compact previews that open in a movable, resizable window (a full-screen sheet on mobile); file attachments show an accessible filename and remove action without an image-open control. Unsupported selections are ignored, and if one upload fails after task creation, the task remains created while Quick Add reports the filenames that need retrying. The same bottom action row places the GitHub tracking override beside the paperclip; Priority is an icon-only control whose glyph changes by selected level (down arrow for low, flag for normal, up arrow for high, alert for urgent) and is color-coded by urgency (low blue/info, normal muted, high amber/warning, urgent red/error), and Fast is an icon-only lightning control. These icon-only controls keep accessible labels and the same create-payload behavior as the previous text chips.

View File

@@ -991,31 +991,26 @@ 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.
FNXC:QuickAddStart 2026-07-24-11:20:
Start replaced the hidden long-press/right-click Save menu (undiscoverable) with a visible chip in the options
group. It must read as a secondary CTA — clearly actionable, but subordinate to Save's `btn-task-create` fill —
so it uses a translucent `color-mix` wash of the shared CTA tokens rather than a new color. Box metrics come
from the row-wide `.quick-entry-actions .btn` height contract above; do not restate them here.
*/
.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-button {
background: color-mix(in srgb, var(--cta-bg) 35%, transparent);
border-color: color-mix(in srgb, var(--cta-border) 55%, transparent);
color: var(--cta-text);
}
.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-button:hover:not(:disabled),
.quick-entry-start-button:focus-visible:not(:disabled) {
background: color-mix(in srgb, var(--cta-bg-hover) 55%, transparent);
border-color: var(--cta-border-hover);
}
.quick-entry-start-menu button:hover,
.quick-entry-start-menu button:focus-visible {
background: var(--card-hover);
.quick-entry-start-button:disabled {
background: color-mix(in srgb, var(--cta-bg) 12%, transparent);
border-color: color-mix(in srgb, var(--cta-border) 25%, transparent);
color: var(--text-muted);
}

View File

@@ -8,7 +8,7 @@ import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep, Thinki
import type { ModelInfo, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition, NodeInfo } from "../api";
import { checkDuplicateTasks, fetchModels, fetchSettings, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api";
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Zap, Eye, EyeOff } from "lucide-react";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Zap, Eye, EyeOff, Play } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { LoadingSpinner } from "./LoadingSpinner";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
@@ -172,13 +172,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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 previousProjectIdRef = useRef(projectId);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
@@ -373,15 +367,17 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const selectedWorkflowForCreate = workflowId === undefined ? undefined : quickEntryWorkflowId;
const validatedStartWorkflow = useMemo(() => validateQuickAddStartWorkflow(selectedQuickEntryWorkflow), [selectedQuickEntryWorkflow]);
const startInitialColumn = validatedStartWorkflow ? resolveQuickAddStartInitialColumn(validatedStartWorkflow) : null;
/*
FNXC:QuickAddStart 2026-07-24-11:20:
Start is a VISIBLE button in the quick-add action row for eligible workflows only, replacing the hidden
long-press/right-click Save menu that operators could not discover. Eligibility is unchanged
(`workflowSupportsQuickAddStart`: Coding (Ideas), or any workflow whose first visible lane is a hold/"waiting"
column) and a provable target is still required (`startInitialColumn` for the create-time column override, or
`onMoveTask` for the follow-up move). Workflows without a waiting lane render no Start button at all — Save
stays the single create affordance there.
*/
const canQuickAddStart = Boolean(validatedStartWorkflow && workflowSupportsQuickAddStart(validatedStartWorkflow) && (startInitialColumn || onMoveTask));
const canOpenQuickAddStartMenu = canQuickAddStart && Boolean(description.trim()) && !isSubmitting;
useEffect(() => {
if (!canOpenQuickAddStartMenu) {
setShowStartMenu(false);
setStartMenuPosition(null);
}
}, [canOpenQuickAddStartMenu]);
const canQuickAddStartNow = canQuickAddStart && Boolean(description.trim()) && !isSubmitting;
useEffect(() => {
const parentChanged = previousWorkflowDefaultRef.current.workflowId !== workflowId
@@ -1693,51 +1689,17 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
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(() => {
if (suppressSaveClickRef.current) {
suppressSaveClickRef.current = false;
return;
}
handleSubmit();
}, [handleSubmit]);
/*
FNXC:QuickAddStart 2026-07-24-11:20:
Start stashes the workflow snapshot validated at click time in `startIntentRef` and then runs the SAME submit
path as Save. The snapshot (not live state) is what `submitCreateTask` reads, so a workflow list refreshed
mid-duplicate-confirmation cannot retarget an in-flight Start.
*/
const handleStartClick = useCallback(() => {
if (!canOpenQuickAddStartMenu || !validatedStartWorkflow) return;
if (!canQuickAddStartNow || !validatedStartWorkflow) return;
startIntentRef.current = validatedStartWorkflow;
setShowStartMenu(false);
setStartMenuPosition(null);
handleSubmit();
}, [canOpenQuickAddStartMenu, handleSubmit, validatedStartWorkflow]);
}, [canQuickAddStartNow, handleSubmit, validatedStartWorkflow]);
const truncate = (s: string, len: number) =>
s.length > len ? s.slice(0, len) + "…" : s;
@@ -2340,6 +2302,29 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
</div>,
portalRoot,
)}
{/*
FNXC:QuickAddStart 2026-07-24-11:20:
Start renders as the last chip in the options group so it wraps onto the same line as Models/Agent and
reads as an alternate create action beside Save (which stays right-aligned in the primary group). It is
present ONLY for hold-first/"waiting"-column workflows — most workflows show no Start chip. With an
empty description it stays visible but DISABLED (matching Save) so the affordance does not appear and
vanish as the operator types; the whole action row still unmounts while a create is in flight.
*/}
{canQuickAddStart && (
<button
type="button"
className="btn btn-sm quick-entry-start-button"
onClick={handleStartClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!canQuickAddStartNow}
data-testid="quick-entry-save-start"
title={t("tasks.startTaskTitle", "Create and start the task")}
>
<Play size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("tasks.start", "Start")}
</button>
)}
</div>
{/*
@@ -2503,30 +2488,17 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
</button>
<button
ref={saveButtonRef}
type="button"
className="btn btn-task-create btn-sm"
onClick={handleSaveClick}
onContextMenu={(event) => { if (canOpenQuickAddStartMenu) { event.preventDefault(); openStartMenu(); } }}
onPointerDown={handleSavePointerDown}
onPointerMove={handleSavePointerMove}
onPointerLeave={clearLongPress}
onPointerUp={clearLongPress}
onPointerCancel={clearLongPress}
onClick={handleSubmit}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim() || isSubmitting}
aria-haspopup={canOpenQuickAddStartMenu ? "menu" : undefined}
data-testid="quick-entry-save"
title={t("tasks.createTaskTitle", "Create task")}
>
<Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("tasks.save", "Save")}
</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>
)}

View File

@@ -221,6 +221,7 @@ vi.mock("lucide-react", () => {
ListTree: MockIcon("lucide-list-tree"),
Sparkles: MockIcon("lucide-sparkles"),
Save: MockIcon("lucide-save"),
Play: MockIcon("lucide-play"),
X: MockIcon("lucide-x"),
ChevronDown: MockIcon("lucide-chevron-down"),
ChevronUp: MockIcon("lucide-chevron-up"),
@@ -5471,7 +5472,15 @@ describe("QuickEntryBox", () => {
});
});
describe("Quick Add Start menu", () => {
/*
FNXC:QuickAddStart 2026-07-24-11:20:
Start is a visible action-row button, not a long-press/right-click menu. Surface enumeration for the affordance
swap: both breakpoints (desktop + mobile), both start mechanisms (Coding (Ideas) create-time column override and
the hold-first follow-up move), empty vs populated description (disabled vs enabled), ineligible/malformed
workflow metadata (no button and no orphaned shell), and the removed gestures (long-press and context menu must
no longer produce any Start surface on Save).
*/
describe("Quick Add Start button", () => {
const ideasWorkflow = {
id: "builtin:coding-ideas",
name: "Coding (Ideas)",
@@ -5482,69 +5491,75 @@ describe("QuickEntryBox", () => {
],
};
const enterDescription = () => fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Start this task" } });
const openStartByContextMenu = () => fireEvent.contextMenu(screen.getByTestId("quick-entry-save"));
const holdFirstWorkflow = {
id: "custom:waiting",
name: "Waiting first",
columns: [
{ id: "waiting", name: "Waiting", flags: { hold: true } },
{ id: "working", name: "Working", flags: {} },
{ id: "done", name: "Done", flags: { complete: true } },
],
};
it("creates Coding Ideas Start in Todo from mouse right-click without a follow-up move", async () => {
mockDesktopViewport();
const enterDescription = () => fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Start this task" } });
const clickStart = () => fireEvent.click(screen.getByTestId("quick-entry-save-start"));
it.each([
["desktop", mockDesktopViewport],
["mobile", mockMobileViewport],
])("creates Coding Ideas Start in Todo from the visible button on %s without a follow-up move", async (_label, mockViewport) => {
mockViewport();
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" }));
clickStart();
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ description: "Start this task", workflowId: ideasWorkflow.id, column: "todo" })));
expect(onMoveTask).not.toHaveBeenCalled();
});
it("opens Start after touch and pen long-press, suppresses the compatibility click, and cancels moved/up/cancelled pointers", async () => {
mockMobileViewport();
const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-touch", column: "ideas", workflowId: ideasWorkflow.id });
it("moves a hold-first workflow's created task to the first working column", async () => {
const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-hold", column: "waiting", workflowId: holdFirstWorkflow.id });
const onMoveTask = vi.fn().mockResolvedValue({});
const touchRender = renderQuickEntryBox({ onCreate, onMoveTask, workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] });
renderQuickEntryBox({ onCreate, onMoveTask, workflowId: holdFirstWorkflow.id, workflowOptions: [holdFirstWorkflow] });
enterDescription();
clickStart();
await waitFor(() => expect(onCreate).toHaveBeenCalled());
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty("column");
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-hold", "working"));
});
it("disables Start until a description is entered and never hides it mid-typing", () => {
renderQuickEntryBox({ onMoveTask: vi.fn(), workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] });
expect(screen.getByTestId("quick-entry-save-start")).toBeDisabled();
enterDescription();
expect(screen.getByTestId("quick-entry-save-start")).not.toBeDisabled();
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "" } });
expect(screen.getByTestId("quick-entry-save-start")).toBeDisabled();
});
it("no longer exposes Start through Save long-press or right-click", async () => {
const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-gesture", column: "ideas", workflowId: ideasWorkflow.id });
renderQuickEntryBox({ onCreate, onMoveTask: vi.fn(), workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] });
enterDescription();
const save = screen.getByTestId("quick-entry-save");
expect(save).not.toHaveAttribute("aria-haspopup");
fireEvent.contextMenu(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(onCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: ideasWorkflow.id, column: "todo" })));
expect(onMoveTask).not.toHaveBeenCalled();
await waitFor(() => expect(save).not.toBeDisabled());
touchRender.unmount();
await act(async () => { vi.advanceTimersByTime(1000); });
expect(screen.queryByRole("menu")).toBeNull();
expect(screen.queryByRole("menuitem")).toBeNull();
const penOnCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-pen", column: "todo", workflowId: ideasWorkflow.id });
const penOnMoveTask = vi.fn();
const { unmount } = renderQuickEntryBox({ onCreate: penOnCreate, onMoveTask: penOnMoveTask, workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] });
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Pen task" } });
const penSave = screen.getAllByTestId("quick-entry-save").at(-1)!;
fireEvent.pointerDown(penSave, { pointerType: "pen", pointerId: 2, clientX: 20, clientY: 20 });
await act(async () => { vi.advanceTimersByTime(550); });
expect(screen.getByTestId("quick-entry-save-start")).toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: "Start" }));
await waitFor(() => expect(penOnCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: ideasWorkflow.id, column: "todo" })));
expect(penOnMoveTask).not.toHaveBeenCalled();
unmount();
const cancelled = renderQuickEntryBox({ onMoveTask: vi.fn(), workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] });
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();
// The plain Save click still creates in the workflow's own intake column (no Start column override).
clickSave();
await waitFor(() => expect(onCreate).toHaveBeenCalled());
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty("column");
});
it("preserves the submitted Start workflow snapshot through duplicate confirmation", async () => {
@@ -5553,8 +5568,7 @@ describe("QuickEntryBox", () => {
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" }));
clickStart();
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: {} }] }]} />);
@@ -5564,21 +5578,20 @@ describe("QuickEntryBox", () => {
expect(onMoveTask).not.toHaveBeenCalled();
});
it("has no Start shell for unrelated or malformed workflow metadata and keeps Save create-only", async () => {
it("renders no Start button or shell for unrelated or malformed workflow metadata and keeps Save create-only", async () => {
const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-plain", column: "ideas", workflowId: "custom" });
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();
expect(screen.queryByRole("button", { name: "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");
expect(screen.queryByTestId("quick-entry-save-start")).toBeNull();
});
it("treats void and invalid create results as create-only paths without a move", async () => {
@@ -5594,9 +5607,7 @@ describe("QuickEntryBox", () => {
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" }));
clickStart();
await waitFor(() => expect(onCreate).toHaveBeenCalled());
expect(onMoveTask).not.toHaveBeenCalled();
unmount();