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:
5
.changeset/new-task-duplicates.md
Normal file
5
.changeset/new-task-duplicates.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
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).
|
||||
|
||||
## New Task Modal Branch Strategy
|
||||
|
||||
@@ -45,9 +45,11 @@
|
||||
}
|
||||
|
||||
.duplicate-warning-modal-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.duplicate-warning-modal-actions {
|
||||
|
||||
@@ -18,6 +18,10 @@ export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }:
|
||||
const { t } = useTranslation("app");
|
||||
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(() => {
|
||||
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="duplicate-warning-modal-score">{Math.round(match.score * 100)}%</span>
|
||||
</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">
|
||||
<button className="btn btn-sm" type="button" onClick={() => onOpen(match.id)}>{t("duplicateWarning.open", "Open")}</button>
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,16 @@ import "./NewTaskModal.css";
|
||||
import { useState, useCallback, useEffect, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment } from "../api";
|
||||
import { checkDuplicateTasks, uploadAttachment, type CreateTaskInput, type DuplicateMatch } from "../api";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useSetupReadiness } from "../hooks/useSetupReadiness";
|
||||
import { SetupWarningBanner } from "./SetupWarningBanner";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { TaskForm, type BranchSelectionMode, type PendingImage } from "./TaskForm";
|
||||
import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
||||
import { REPO_OVERRIDE_RE } from "./githubTracking";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
@@ -20,12 +21,20 @@ import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||||
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
|
||||
|
||||
type NewTaskCreateInput = Omit<CreateTaskInput, "branchSelection"> & {
|
||||
branchSelection?: {
|
||||
mode: BranchSelectionMode;
|
||||
branchName?: string;
|
||||
baseBranch?: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface NewTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId?: string;
|
||||
tasks: Task[]; // for dependency selection
|
||||
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
|
||||
onCreateTask: (input: NewTaskCreateInput) => Promise<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
initialDescription?: string;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
@@ -290,6 +299,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
|
||||
const [executorModel, setExecutorModel] = useState("");
|
||||
const [validatorModel, setValidatorModel] = useState("");
|
||||
const [planningModel, setPlanningModel] = useState("");
|
||||
@@ -311,6 +321,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 [githubTrackingEnabled, setGithubTrackingEnabled] = useState(false);
|
||||
@@ -459,6 +472,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setHasDirtyState(false);
|
||||
setGithubTrackingEnabled(false);
|
||||
setGithubRepoOverride("");
|
||||
setDuplicateMatches(null);
|
||||
}, [pendingImages]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
@@ -483,116 +497,140 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
onClose();
|
||||
}, [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 trimmedDesc = description.trim();
|
||||
if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
let keepSubmittingForDuplicateChoice = false;
|
||||
try {
|
||||
const executorSlashIdx = executorModel.indexOf("/");
|
||||
const validatorSlashIdx = validatorModel.indexOf("/");
|
||||
const planningSlashIdx = planningModel.indexOf("/");
|
||||
|
||||
const createInput: TaskCreateInput & {
|
||||
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");
|
||||
}
|
||||
const matches = await checkDuplicateTasks({ description: trimmedDesc }, projectId);
|
||||
if (matches.length > 0) {
|
||||
setDuplicateMatches(matches);
|
||||
keepSubmittingForDuplicateChoice = true;
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
addToast(t("tasks.duplicateCheckFailed", "Duplicate check failed; creating task anyway."), "error");
|
||||
}
|
||||
|
||||
// Clean up
|
||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setPendingImages([]);
|
||||
setDescription("");
|
||||
setDependencies([]);
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
setPlanningModel("");
|
||||
setThinkingLevel("");
|
||||
setSelectedPresetId("");
|
||||
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("");
|
||||
try {
|
||||
await performCreate(trimmedDesc);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("newTaskModal.failedToCreate", "Failed to create task"), "error");
|
||||
} finally {
|
||||
if (!keepSubmittingForDuplicateChoice) {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
}, [description, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, projectId, addToast, t, performCreate]);
|
||||
|
||||
addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success");
|
||||
onClose();
|
||||
const handleDuplicateOpen = useCallback((taskId: string) => {
|
||||
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) {
|
||||
addToast(getErrorMessage(err) || t("newTaskModal.failedToCreate", "Failed to create task"), "error");
|
||||
} finally {
|
||||
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
|
||||
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.
|
||||
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
|
||||
className={`modal modal-lg new-task-modal${isFloating ? " new-task-modal--floating" : ""}`}
|
||||
style={panelStyle}
|
||||
onPointerDownCapture={isFloating ? bringToFront : undefined}
|
||||
onFocusCapture={isFloating ? bringToFront : undefined}
|
||||
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}
|
||||
>
|
||||
{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}
|
||||
className={`modal modal-lg new-task-modal${isFloating ? " new-task-modal--floating" : ""}`}
|
||||
style={panelStyle}
|
||||
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>
|
||||
<button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label={t("actions.close", "Close")}>
|
||||
×
|
||||
@@ -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="modal-actions">
|
||||
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
|
||||
>
|
||||
{isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")}
|
||||
</button>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
|
||||
>
|
||||
{isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
{duplicateMatches && (
|
||||
<DuplicateWarningModal
|
||||
matches={duplicateMatches}
|
||||
onOpen={handleDuplicateOpen}
|
||||
onProceed={handleDuplicateProceed}
|
||||
onCancel={handleDuplicateCancel}
|
||||
/>
|
||||
)}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,18 +4,36 @@ import { DuplicateWarningModal } from "../DuplicateWarningModal";
|
||||
import type { DuplicateMatch } from "../../api";
|
||||
|
||||
const matches: DuplicateMatch[] = [
|
||||
{ id: "FN-101", title: "Fix duplicate task flow", description: "...", column: "todo", score: 0.81 },
|
||||
{ id: "FN-102", title: "Another duplicate", description: "...", column: "in-progress", score: 0.67 },
|
||||
{ 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: "Detect duplicates before saving full dialog tasks", column: "in-progress", score: 0.67 },
|
||||
];
|
||||
|
||||
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()} />);
|
||||
|
||||
expect(screen.getByText("FN-101")).toBeInTheDocument();
|
||||
expect(screen.getByText("FN-102")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fix duplicate task flow")).toBeInTheDocument();
|
||||
expect(screen.getByText("Another duplicate")).toBeInTheDocument();
|
||||
expect(screen.getByText("Prevent duplicate tasks from the quick entry surface")).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", () => {
|
||||
|
||||
@@ -3,6 +3,9 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { NewTaskModal } from "../NewTaskModal";
|
||||
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
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -23,6 +26,7 @@ vi.mock("lucide-react", () => ({
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
uploadAttachment: vi.fn().mockResolvedValue({}),
|
||||
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [
|
||||
{ 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 },
|
||||
@@ -100,6 +104,7 @@ describe("NewTaskModal", () => {
|
||||
mockViewportMode = "mobile";
|
||||
mockConfirm.mockReset();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: false,
|
||||
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", () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
Reference in New Issue
Block a user