FN-6930: add New Task duplicate preflight

Add duplicate detection parity to full-dialog task creation and improve duplicate warnings.\n\n- Run duplicate checks before New Task dialog creation and require explicit acknowledgement to create anyway.\n- Show duplicate task descriptions with title and empty-state fallbacks in the warning modal.\n- Cover duplicate warning and New Task duplicate flows with dashboard tests.\n- Document duplicate handling parity and add a published CLI changeset.\n\nFiles changed:\n .changeset/new-task-duplicates.md                  |   5 +\n docs/dashboard-guide.md                            |   4 +\n .../app/components/DuplicateWarningModal.css       |   6 +-\n .../app/components/DuplicateWarningModal.tsx       |   6 +-\n packages/dashboard/app/components/NewTaskModal.tsx | 324 ++++++++++++---------\n .../__tests__/DuplicateWarningModal.test.tsx       |  28 +-\n .../app/components/__tests__/NewTaskModal.test.tsx | 104 +++++++\n 7 files changed, 331 insertions(+), 146 deletions(-)

Fusion-Task-Id: FN-6930

Fusion-Task-Lineage: 06d75ec5-e7e8-4124-9b08-47a7adad4fad
This commit is contained in:
gsxdsm
2026-06-22 23:42:45 -07:00
parent 9218613552
commit a6685b7f03
7 changed files with 331 additions and 146 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Check for duplicate tasks from the New Task dialog and show duplicate descriptions in the warning modal.

View File

@@ -291,6 +291,10 @@ These values are sent with the Planning Mode create-task request as `branchSelec
When inline quick-create, Planning Mode, or Subtask Breakdown is opened from a workflow-filtered board/list lane, the create request also carries that active workflow selection. Quick-created tasks appear on the selected workflow lane immediately while board-workflows metadata refreshes, and planning saves, planning breakdown saves, and subtask-breakdown saves create their tasks directly on the selected workflow lane instead of briefly landing on the default board. When inline quick-create, Planning Mode, or Subtask Breakdown is opened from a workflow-filtered board/list lane, the create request also carries that active workflow selection. Quick-created tasks appear on the selected workflow lane immediately while board-workflows metadata refreshes, and planning saves, planning breakdown saves, and subtask-breakdown saves create their tasks directly on the selected workflow lane instead of briefly landing on the default board.
The **New Task** dialog's workflow selector also defaults to the current or last selected Board/List workflow lane for the current project. If no valid lane has been selected, or the remembered lane was deleted, the selector falls back to the project default workflow and task creation omits an explicit `workflowId`.
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.
Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer. History rows are deduplicated by session id even if the initial load and live session updates arrive out of order, and deleting a history entry now waits for the server delete to persist (failures keep the row visible and surface an error instead of silently disappearing until refresh). Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer. History rows are deduplicated by session id even if the initial load and live session updates arrive out of order, and deleting a history entry now waits for the server delete to persist (failures keep the row visible and surface an error instead of silently disappearing until refresh).
## New Task Modal Branch Strategy ## New Task Modal Branch Strategy

View File

@@ -45,9 +45,11 @@
} }
.duplicate-warning-modal-title { .duplicate-warning-modal-title {
display: -webkit-box;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; -webkit-box-orient: vertical;
white-space: nowrap; -webkit-line-clamp: 3;
word-break: break-word;
} }
.duplicate-warning-modal-actions { .duplicate-warning-modal-actions {

View File

@@ -18,6 +18,10 @@ export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }:
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const cancelButtonRef = useRef<HTMLButtonElement>(null); const cancelButtonRef = useRef<HTMLButtonElement>(null);
// FNXC:DuplicateWarning 2026-06-22-02:14: Duplicate warnings must show the task description first so users compare the actual requested work, then fall back to title and an explicit empty-state label.
const getMatchDisplayText = (match: DuplicateMatch) =>
match.description.trim() || match.title.trim() || t("duplicateWarning.untitledTask", "No description");
useEffect(() => { useEffect(() => {
cancelButtonRef.current?.focus(); cancelButtonRef.current?.focus();
}, []); }, []);
@@ -49,7 +53,7 @@ export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }:
<span className={`card-status-badge ${toStatusClass(match.column)}`}>{match.column}</span> <span className={`card-status-badge ${toStatusClass(match.column)}`}>{match.column}</span>
<span className="duplicate-warning-modal-score">{Math.round(match.score * 100)}%</span> <span className="duplicate-warning-modal-score">{Math.round(match.score * 100)}%</span>
</div> </div>
<div className="card-title duplicate-warning-modal-title">{match.title || t("duplicateWarning.untitledTask", "Untitled task")}</div> <div className="card-title duplicate-warning-modal-title">{getMatchDisplayText(match)}</div>
<div className="duplicate-warning-modal-actions"> <div className="duplicate-warning-modal-actions">
<button className="btn btn-sm" type="button" onClick={() => onOpen(match.id)}>{t("duplicateWarning.open", "Open")}</button> <button className="btn btn-sm" type="button" onClick={() => onOpen(match.id)}>{t("duplicateWarning.open", "Open")}</button>
</div> </div>

View File

@@ -2,15 +2,16 @@ import "./NewTaskModal.css";
import { useState, useCallback, useEffect, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; import { useState, useCallback, useEffect, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, type Task, type TaskPriority } from "@fusion/core";
import { getErrorMessage } from "@fusion/core"; import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { uploadAttachment } from "../api"; import { checkDuplicateTasks, uploadAttachment, type CreateTaskInput, type DuplicateMatch } from "../api";
import { Bot } from "lucide-react"; import { Bot } from "lucide-react";
import { useSetupReadiness } from "../hooks/useSetupReadiness"; import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner"; import { SetupWarningBanner } from "./SetupWarningBanner";
import { LoadingSpinner } from "./LoadingSpinner"; import { LoadingSpinner } from "./LoadingSpinner";
import { TaskForm, type BranchSelectionMode, type PendingImage } from "./TaskForm"; import { TaskForm, type BranchSelectionMode, type PendingImage } from "./TaskForm";
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { REPO_OVERRIDE_RE } from "./githubTracking"; import { REPO_OVERRIDE_RE } from "./githubTracking";
import { useConfirm } from "../hooks/useConfirm"; import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
@@ -20,12 +21,20 @@ import { useViewportMode } from "../hooks/useViewportMode";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
type NewTaskCreateInput = Omit<CreateTaskInput, "branchSelection"> & {
branchSelection?: {
mode: BranchSelectionMode;
branchName?: string;
baseBranch?: string;
};
};
interface NewTaskModalProps { interface NewTaskModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
projectId?: string; projectId?: string;
tasks: Task[]; // for dependency selection tasks: Task[]; // for dependency selection
onCreateTask: (input: TaskCreateInput) => Promise<Task>; onCreateTask: (input: NewTaskCreateInput) => Promise<Task>;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
initialDescription?: string; initialDescription?: string;
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
@@ -290,6 +299,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [baseBranch, setBaseBranch] = useState(""); const [baseBranch, setBaseBranch] = useState("");
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]); const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
const [executorModel, setExecutorModel] = useState(""); const [executorModel, setExecutorModel] = useState("");
const [validatorModel, setValidatorModel] = useState(""); const [validatorModel, setValidatorModel] = useState("");
const [planningModel, setPlanningModel] = useState(""); const [planningModel, setPlanningModel] = useState("");
@@ -311,6 +321,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
/** /**
* FNXC:NewTaskDialogAffordances 2026-06-21-18:35: * FNXC:NewTaskDialogAffordances 2026-06-21-18:35:
* The New Task dialog must expose the same Fast/standard execution-mode affordance as QuickEntryBox's `quick-entry-fast-toggle`. Reuse TaskForm's `task-form-execution-mode-select` and forward only Fast into `TaskCreateInput.executionMode` so Standard keeps the store default. * The New Task dialog must expose the same Fast/standard execution-mode affordance as QuickEntryBox's `quick-entry-fast-toggle`. Reuse TaskForm's `task-form-execution-mode-select` and forward only Fast into `TaskCreateInput.executionMode` so Standard keeps the store default.
*
* FNXC:NewTaskDialogAffordances 2026-06-22-02:14:
* Full-dialog task creation must run the same duplicate preflight as QuickEntryBox before creating. Keep acknowledged duplicate IDs in the create payload so the API receives an explicit user confirmation when the user chooses Create anyway.
*/ */
const [executionMode, setExecutionMode] = useState<"standard" | "fast">("standard"); const [executionMode, setExecutionMode] = useState<"standard" | "fast">("standard");
const [githubTrackingEnabled, setGithubTrackingEnabled] = useState(false); const [githubTrackingEnabled, setGithubTrackingEnabled] = useState(false);
@@ -459,6 +472,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setHasDirtyState(false); setHasDirtyState(false);
setGithubTrackingEnabled(false); setGithubTrackingEnabled(false);
setGithubRepoOverride(""); setGithubRepoOverride("");
setDuplicateMatches(null);
}, [pendingImages]); }, [pendingImages]);
const handleClose = useCallback(async () => { const handleClose = useCallback(async () => {
@@ -483,116 +497,140 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onClose(); onClose();
}, [onClose, resetForm]); }, [onClose, resetForm]);
const performCreate = useCallback(async (trimmedDesc: string, acknowledgedDuplicates?: string[]) => {
const executorSlashIdx = executorModel.indexOf("/");
const validatorSlashIdx = validatorModel.indexOf("/");
const planningSlashIdx = planningModel.indexOf("/");
const createInput: NewTaskCreateInput = {
title: undefined,
description: trimmedDesc,
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
// U6/R3: forward the workflow selection only when the user changed it.
// - undefined → omit (store inherits the project default, today's behavior)
// - null → explicit "No workflow" (store skips default materialization)
// - string → that workflow, materialized atomically at create time.
...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}),
// Optional steps the user toggled on (omit when none so the store keeps its
// default materialization behavior).
...(enabledWorkflowSteps.length ? { enabledWorkflowSteps } : {}),
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined,
validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined,
planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined,
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" | "xhigh" : undefined,
reviewLevel,
...(autoMerge !== undefined ? { autoMerge } : {}),
priority,
nodeId,
...(executionMode === "fast" ? { executionMode: "fast" } : {}),
branchSelection: {
mode: branchMode,
...(isBranchNameRequired && branch.trim() ? { branchName: branch.trim() } : {}),
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
},
...(acknowledgedDuplicates?.length ? { acknowledgedDuplicates } : {}),
...(githubTrackingEnabled || githubRepoOverrideTrimmed !== ""
? {
githubTracking: {
enabled: githubTrackingEnabled,
...(githubRepoOverrideTrimmed !== "" ? { repoOverride: githubRepoOverrideTrimmed } : {}),
},
}
: {}),
};
// U6/R3: the workflow is now materialized atomically inside createTask via
// the `workflowId` parameter — no post-create selectTaskWorkflow call, so
// the executor can never observe the task with the wrong step set.
const task = await onCreateTask(createInput);
// Upload pending images as attachments
if (pendingImages.length > 0) {
const failures: string[] = [];
for (const img of pendingImages) {
try {
await uploadAttachment(task.id, img.file, projectId);
} catch {
failures.push(img.file.name);
}
}
if (failures.length > 0) {
addToast(t("newTaskModal.failedToUpload", "Failed to upload: {{files}}", { files: failures.join(", ") }), "error");
}
}
resetForm();
addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success");
onClose();
}, [executorModel, validatorModel, planningModel, thinkingLevel, dependencies, selectedWorkflowId, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, pendingImages, resetForm, addToast, t, onClose, projectId]);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
const trimmedDesc = description.trim(); const trimmedDesc = description.trim();
if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection) return; if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection) return;
setIsSubmitting(true); setIsSubmitting(true);
let keepSubmittingForDuplicateChoice = false;
try { try {
const executorSlashIdx = executorModel.indexOf("/"); const matches = await checkDuplicateTasks({ description: trimmedDesc }, projectId);
const validatorSlashIdx = validatorModel.indexOf("/"); if (matches.length > 0) {
const planningSlashIdx = planningModel.indexOf("/"); setDuplicateMatches(matches);
keepSubmittingForDuplicateChoice = true;
const createInput: TaskCreateInput & { return;
branchSelection?: {
mode: BranchSelectionMode;
branchName?: string;
baseBranch?: string;
};
} = {
title: undefined,
description: trimmedDesc,
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
// U6/R3: forward the workflow selection only when the user changed it.
// - undefined → omit (store inherits the project default, today's behavior)
// - null → explicit "No workflow" (store skips default materialization)
// - string → that workflow, materialized atomically at create time.
...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}),
// Optional steps the user toggled on (omit when none so the store keeps its
// default materialization behavior).
...(enabledWorkflowSteps.length ? { enabledWorkflowSteps } : {}),
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined,
validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined,
planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined,
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" | "xhigh" : undefined,
reviewLevel,
...(autoMerge !== undefined ? { autoMerge } : {}),
priority,
nodeId,
...(executionMode === "fast" ? { executionMode: "fast" } : {}),
branchSelection: {
mode: branchMode,
...(isBranchNameRequired && branch.trim() ? { branchName: branch.trim() } : {}),
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
},
...(githubTrackingEnabled || githubRepoOverrideTrimmed !== ""
? {
githubTracking: {
enabled: githubTrackingEnabled,
...(githubRepoOverrideTrimmed !== "" ? { repoOverride: githubRepoOverrideTrimmed } : {}),
},
}
: {}),
};
// U6/R3: the workflow is now materialized atomically inside createTask via
// the `workflowId` parameter — no post-create selectTaskWorkflow call, so
// the executor can never observe the task with the wrong step set.
const task = await onCreateTask(createInput);
// Upload pending images as attachments
if (pendingImages.length > 0) {
const failures: string[] = [];
for (const img of pendingImages) {
try {
await uploadAttachment(task.id, img.file, projectId);
} catch {
failures.push(img.file.name);
}
}
if (failures.length > 0) {
addToast(t("newTaskModal.failedToUpload", "Failed to upload: {{files}}", { files: failures.join(", ") }), "error");
}
} }
} catch (_error) {
addToast(t("tasks.duplicateCheckFailed", "Duplicate check failed; creating task anyway."), "error");
}
// Clean up try {
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); await performCreate(trimmedDesc);
setPendingImages([]); } catch (err) {
setDescription(""); addToast(getErrorMessage(err) || t("newTaskModal.failedToCreate", "Failed to create task"), "error");
setDependencies([]); } finally {
setExecutorModel(""); if (!keepSubmittingForDuplicateChoice) {
setValidatorModel(""); setIsSubmitting(false);
setPlanningModel(""); }
setThinkingLevel(""); }
setSelectedPresetId(""); }, [description, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, projectId, addToast, t, performCreate]);
setPresetMode("default");
setSelectedWorkflowId(undefined);
setEnabledWorkflowSteps([]);
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
setAutoMerge(undefined);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
setExecutionMode("standard");
setBranchMode("project-default");
setBranch("");
setBaseBranch("");
addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success"); const handleDuplicateOpen = useCallback((taskId: string) => {
onClose(); setDuplicateMatches(null);
if (typeof window !== "undefined") {
window.location.hash = `#/tasks/${taskId}`;
}
resetForm();
onClose();
}, [onClose, resetForm]);
const handleDuplicateProceed = useCallback(async () => {
const trimmedDesc = description.trim();
const matches = duplicateMatches;
if (!trimmedDesc || !matches || matches.length === 0) {
setDuplicateMatches(null);
setIsSubmitting(false);
return;
}
setDuplicateMatches(null);
setIsSubmitting(true);
try {
await performCreate(trimmedDesc, matches.map((match) => match.id));
} catch (err) { } catch (err) {
addToast(getErrorMessage(err) || t("newTaskModal.failedToCreate", "Failed to create task"), "error"); addToast(getErrorMessage(err) || t("newTaskModal.failedToCreate", "Failed to create task"), "error");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, enabledWorkflowSteps, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]); }, [description, duplicateMatches, performCreate, addToast, t]);
const handleDuplicateCancel = useCallback(() => {
setDuplicateMatches(null);
setIsSubmitting(false);
}, []);
// Handle keyboard shortcuts // Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -749,37 +787,38 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the floating New Task dialog shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly at the document root. Mobile sheet is position:fixed, unaffected. // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the floating New Task dialog shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly at the document root. Mobile sheet is position:fixed, unaffected.
return createPortal( return createPortal(
<div <>
className="modal-overlay open new-task-modal-overlay"
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="false"
aria-label={t("newTaskModal.title", "New Task")}
data-testid="new-task-modal-overlay"
/* FNXC:FloatingWindow 2026-06-22-23:00: In floating mode the z-index lives on the fixed overlay (it owns the stacking context); a panel z is trapped and loses to page stacking contexts like the right dock. Mobile keeps its CSS z. */
style={isFloating ? { zIndex } : undefined}
>
<div <div
className={`modal modal-lg new-task-modal${isFloating ? " new-task-modal--floating" : ""}`} className="modal-overlay open new-task-modal-overlay"
style={panelStyle} onKeyDown={handleKeyDown}
onPointerDownCapture={isFloating ? bringToFront : undefined} role="dialog"
onFocusCapture={isFloating ? bringToFront : undefined} aria-modal="false"
aria-label={t("newTaskModal.title", "New Task")}
data-testid="new-task-modal-overlay"
/* FNXC:FloatingWindow 2026-06-22-23:00: In floating mode the z-index lives on the fixed overlay (it owns the stacking context); a panel z is trapped and loses to page stacking contexts like the right dock. Mobile keeps its CSS z. */
style={isFloating ? { zIndex } : undefined}
> >
{isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`new-task-resize-handle new-task-resize-handle--${direction}`}
data-testid={`new-task-resize-${direction}`}
role="separator"
aria-label={t("newTaskModal.resize", "Resize new task window")}
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
/>
))}
<div <div
className={`modal-header${isFloating ? " new-task-modal__header--draggable" : ""}`} className={`modal modal-lg new-task-modal${isFloating ? " new-task-modal--floating" : ""}`}
data-testid="new-task-drag-handle" style={panelStyle}
onPointerDown={isFloating ? handleFloatingDragPointerDown : undefined} onPointerDownCapture={isFloating ? bringToFront : undefined}
onFocusCapture={isFloating ? bringToFront : undefined}
> >
{isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`new-task-resize-handle new-task-resize-handle--${direction}`}
data-testid={`new-task-resize-${direction}`}
role="separator"
aria-label={t("newTaskModal.resize", "Resize new task window")}
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
/>
))}
<div
className={`modal-header${isFloating ? " new-task-modal__header--draggable" : ""}`}
data-testid="new-task-drag-handle"
onPointerDown={isFloating ? handleFloatingDragPointerDown : undefined}
>
<h3>{t("newTaskModal.title", "New Task")}</h3> <h3>{t("newTaskModal.title", "New Task")}</h3>
<button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label={t("actions.close", "Close")}> <button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label={t("actions.close", "Close")}>
&times; &times;
@@ -858,20 +897,29 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
<div className="form-error new-task-branch-error">{t("newTaskModal.branchRequired", "Branch name is required for this branch strategy.")}</div> <div className="form-error new-task-branch-error">{t("newTaskModal.branchRequired", "Branch name is required for this branch strategy.")}</div>
)} )}
<div className="modal-actions"> <div className="modal-actions">
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}> <button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
{t("actions.cancel", "Cancel")} {t("actions.cancel", "Cancel")}
</button> </button>
<button <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection} disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
> >
{isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")} {isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")}
</button> </button>
</div>
</div> </div>
</div> </div>
</div>, {duplicateMatches && (
<DuplicateWarningModal
matches={duplicateMatches}
onOpen={handleDuplicateOpen}
onProceed={handleDuplicateProceed}
onCancel={handleDuplicateCancel}
/>
)}
</>,
document.body, document.body,
); );
} }

View File

@@ -4,18 +4,36 @@ import { DuplicateWarningModal } from "../DuplicateWarningModal";
import type { DuplicateMatch } from "../../api"; import type { DuplicateMatch } from "../../api";
const matches: DuplicateMatch[] = [ const matches: DuplicateMatch[] = [
{ id: "FN-101", title: "Fix duplicate task flow", description: "...", column: "todo", score: 0.81 }, { id: "FN-101", title: "Fix duplicate task flow", description: "Prevent duplicate tasks from the quick entry surface", column: "todo", score: 0.81 },
{ id: "FN-102", title: "Another duplicate", description: "...", column: "in-progress", score: 0.67 }, { id: "FN-102", title: "Another duplicate", description: "Detect duplicates before saving full dialog tasks", column: "in-progress", score: 0.67 },
]; ];
describe("DuplicateWarningModal", () => { describe("DuplicateWarningModal", () => {
it("renders one row per match with id and title", () => { it("renders one row per match with id and description", () => {
render(<DuplicateWarningModal matches={matches} onOpen={vi.fn()} onProceed={vi.fn()} onCancel={vi.fn()} />); render(<DuplicateWarningModal matches={matches} onOpen={vi.fn()} onProceed={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByText("FN-101")).toBeInTheDocument(); expect(screen.getByText("FN-101")).toBeInTheDocument();
expect(screen.getByText("FN-102")).toBeInTheDocument(); expect(screen.getByText("FN-102")).toBeInTheDocument();
expect(screen.getByText("Fix duplicate task flow")).toBeInTheDocument(); expect(screen.getByText("Prevent duplicate tasks from the quick entry surface")).toBeInTheDocument();
expect(screen.getByText("Another duplicate")).toBeInTheDocument(); expect(screen.getByText("Detect duplicates before saving full dialog tasks")).toBeInTheDocument();
expect(screen.queryByText("Fix duplicate task flow")).not.toBeInTheDocument();
});
it("falls back from empty description to title then No description", () => {
render(
<DuplicateWarningModal
matches={[
{ id: "FN-201", title: "Title fallback", description: "", column: "todo", score: 0.71 },
{ id: "FN-202", title: "", description: "", column: "todo", score: 0.62 },
]}
onOpen={vi.fn()}
onProceed={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByText("Title fallback")).toBeInTheDocument();
expect(screen.getByText("No description")).toBeInTheDocument();
}); });
it("calls onOpen with the selected id", () => { it("calls onOpen with the selected id", () => {

View File

@@ -3,6 +3,9 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { NewTaskModal } from "../NewTaskModal"; import { NewTaskModal } from "../NewTaskModal";
import type { Task, Column } from "@fusion/core"; import type { Task, Column } from "@fusion/core";
import { checkDuplicateTasks, type BoardWorkflowsPayload } from "../../api";
import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow";
// Mock lucide-react // Mock lucide-react
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => ({
@@ -23,6 +26,7 @@ vi.mock("lucide-react", () => ({
// Mock the api module // Mock the api module
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
uploadAttachment: vi.fn().mockResolvedValue({}), uploadAttachment: vi.fn().mockResolvedValue({}),
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
fetchModels: vi.fn().mockResolvedValue({ models: [ fetchModels: vi.fn().mockResolvedValue({ models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
@@ -100,6 +104,7 @@ describe("NewTaskModal", () => {
mockViewportMode = "mobile"; mockViewportMode = "mobile";
mockConfirm.mockReset(); mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
mockUseMobileKeyboard.mockReturnValue({ mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: false, keyboardOpen: false,
keyboardOverlap: 0, keyboardOverlap: 0,
@@ -722,6 +727,105 @@ describe("NewTaskModal", () => {
}); });
}); });
it("checks for duplicates and creates directly when none are found", async () => {
const { props } = renderNewTaskModal({ projectId: "project-alpha" });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Unique task description" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(checkDuplicateTasks).toHaveBeenCalledWith({ description: "Unique task description" }, "project-alpha");
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ description: "Unique task description" }),
);
});
expect(screen.queryByText("Possible duplicates")).not.toBeInTheDocument();
});
it("shows duplicate warning and does not create when matches are found", async () => {
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-301", title: "Title should not display", description: "Existing similar full-dialog task", column: "todo", score: 0.88 },
]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New full-dialog task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
expect(await screen.findByText("Possible duplicates")).toBeInTheDocument();
expect(screen.getByText("Existing similar full-dialog task")).toBeInTheDocument();
expect(screen.queryByText("Title should not display")).not.toBeInTheDocument();
expect(props.onCreateTask).not.toHaveBeenCalled();
});
it("creates with acknowledged duplicate ids after Create anyway", async () => {
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-401", title: "Existing title", description: "Existing duplicate description", column: "todo", score: 0.93 },
{ id: "FN-402", title: "Second title", description: "Second duplicate description", column: "in-progress", score: 0.82 },
]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Create anyway duplicate" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
fireEvent.click(await screen.findByRole("button", { name: "Create anyway" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Create anyway duplicate",
acknowledgedDuplicates: ["FN-401", "FN-402"],
}),
);
});
});
it("dismisses duplicate warning on Cancel without creating", async () => {
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-501", title: "Existing title", description: "Cancel duplicate description", column: "todo", score: 0.9 },
]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Cancel duplicate" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await screen.findByText("Possible duplicates");
fireEvent.click(screen.getAllByRole("button", { name: "Cancel" }).at(-1)!);
await waitFor(() => {
expect(screen.queryByText("Possible duplicates")).not.toBeInTheDocument();
});
expect(props.onCreateTask).not.toHaveBeenCalled();
});
it("opens the selected duplicate task and closes the dialog", async () => {
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-601", title: "Existing title", description: "Open duplicate description", column: "todo", score: 0.9 },
]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Open duplicate" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
fireEvent.click((await screen.findAllByRole("button", { name: "Open" }))[0]);
await waitFor(() => {
expect(window.location.hash).toBe("#/tasks/FN-601");
expect(props.onClose).toHaveBeenCalled();
});
expect(props.onCreateTask).not.toHaveBeenCalled();
});
it("fails open and creates when duplicate check throws", async () => {
vi.mocked(checkDuplicateTasks).mockRejectedValueOnce(new Error("duplicate check unavailable"));
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Fail open duplicate check" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.addToast).toHaveBeenCalledWith("Duplicate check failed; creating task anyway.", "error");
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ description: "Fail open duplicate check" }),
);
});
});
it("disables Create Task when description is empty", () => { it("disables Create Task when description is empty", () => {
renderNewTaskModal(); renderNewTaskModal();