feat(FN-5060): add InlineCreateCard duplicate warning flow

This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 17:57:20 -07:00
committed by gsxdsm
parent 73202b7eb0
commit f73ffd6386
2 changed files with 131 additions and 34 deletions

View File

@@ -5,11 +5,12 @@ import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot, Max
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskCreateInput, type TaskPriority, type Settings } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents } from "../api";
import type { ModelInfo, Agent, NodeInfo } from "../api";
import { checkDuplicateTasks, fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents, DuplicateCandidatesError } from "../api";
import type { ModelInfo, Agent, NodeInfo, DuplicateMatch } from "../api";
import { useNodes } from "../hooks/useNodes";
import { ModelSelectionModal } from "./ModelSelectionModal";
import { NodeHealthDot } from "./NodeHealthDot";
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { applyPresetToSelection } from "../utils/modelPresets";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
@@ -118,6 +119,8 @@ export function InlineCreateCard({
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
// Track textarea focus for expand button visibility
const [isDescriptionFocused, setIsDescriptionFocused] = useState(false);
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
const [pendingSubmit, setPendingSubmit] = useState<TaskCreateInput | null>(null);
const justResetRef = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
@@ -330,24 +333,10 @@ export function InlineCreateCard({
});
}, []);
const handleSubmit = useCallback(async () => {
if (!description.trim() || submitting) return;
const submitTask = useCallback(async (input: TaskCreateInput) => {
setSubmitting(true);
try {
const task = await onSubmit({
description: description.trim(),
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: selectedPresetId,
modelProvider: hasExecutorOverride ? executorProvider : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined,
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
enabledWorkflowSteps: browserVerification ? ["browser-verification"] : undefined,
priority,
nodeId,
});
const task = await onSubmit(input);
// Upload pending images as attachments
if (pendingImages.length > 0) {
@@ -397,31 +386,79 @@ export function InlineCreateCard({
removeScopedItem(STORAGE_KEY, projectId);
}
} catch (err) {
addToast(getErrorMessage(err), "error");
if (err instanceof DuplicateCandidatesError && err.matches.length > 0) {
setDuplicateMatches(err.matches);
addToast(`Linked existing ${err.matches[0]?.id ?? "task"}`, "success");
} else {
addToast(getErrorMessage(err), "error");
}
} finally {
setSubmitting(false);
}
}, [
description,
dependencies,
selectedAgentId,
hasExecutorOverride,
executorProvider,
executorModelId,
hasValidatorOverride,
validatorProvider,
validatorModelId,
browserVerification,
priority,
submitting,
pendingImages,
onSubmit,
addToast,
projectId,
selectedPresetId,
nodeId,
]);
const handleSubmit = useCallback(async () => {
if (!description.trim() || submitting) return;
const input: TaskCreateInput = {
description: description.trim(),
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: selectedPresetId,
modelProvider: hasExecutorOverride ? executorProvider : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined,
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
enabledWorkflowSteps: browserVerification ? ["browser-verification"] : undefined,
priority,
nodeId,
};
try {
const matches = await checkDuplicateTasks({ description: description.trim() }, projectId);
if (matches.length > 0) {
setDuplicateMatches(matches);
setPendingSubmit(input);
return;
}
} catch {
addToast("Duplicate check failed; creating task anyway.", "error");
}
await submitTask(input);
}, [description, submitting, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, browserVerification, priority, nodeId, projectId, addToast, submitTask]);
const handleDuplicateProceed = useCallback(async () => {
const matches = duplicateMatches;
const input = pendingSubmit;
setDuplicateMatches(null);
setPendingSubmit(null);
if (!matches || !input || matches.length === 0) return;
await submitTask({
...input,
acknowledgedDuplicates: matches.map((match) => match.id),
});
}, [duplicateMatches, pendingSubmit, submitTask]);
const handleDuplicateCancel = useCallback(() => {
setDuplicateMatches(null);
setPendingSubmit(null);
}, []);
const handleDuplicateOpen = useCallback((taskId: string) => {
if (typeof window !== "undefined") {
window.location.hash = `#/tasks/${taskId}`;
}
setDuplicateMatches(null);
setPendingSubmit(null);
}, []);
const handleKeyDown = useCallback(
async (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
@@ -1112,6 +1149,15 @@ export function InlineCreateCard({
document.body,
)
: null}
{duplicateMatches && duplicateMatches.length > 0 ? (
<DuplicateWarningModal
matches={duplicateMatches}
onProceed={handleDuplicateProceed}
onCancel={handleDuplicateCancel}
onOpen={handleDuplicateOpen}
/>
) : null}
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { InlineCreateCard } from "../InlineCreateCard";
import type { Task, Column } from "@fusion/core";
import { fetchModels, fetchSettings, fetchAgents } from "../../api";
import { fetchModels, fetchSettings, fetchAgents, checkDuplicateTasks } from "../../api";
import { useNodes } from "../../hooks/useNodes";
import type { ModelInfo } from "../../api";
import { scopedKey } from "../../utils/projectStorage";
@@ -117,6 +117,14 @@ vi.mock("../../api", () => ({
uploadAttachment: vi.fn(),
updateGlobalSettings: vi.fn(),
fetchAgents: vi.fn().mockResolvedValue([]),
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
DuplicateCandidatesError: class DuplicateCandidatesError extends Error {
matches: unknown[];
constructor(matches: unknown[]) {
super("duplicate_candidates");
this.matches = matches;
}
},
}));
const TEST_PROJECT_ID = "proj-123";
@@ -203,6 +211,7 @@ beforeEach(() => {
unregister: vi.fn(),
healthCheck: vi.fn(),
});
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
vi.mocked(fetchSettings).mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
@@ -311,6 +320,48 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
});
});
describe("InlineCreateCard duplicate warning", () => {
it("shows duplicate modal and blocks submit when duplicate check finds matches", async () => {
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-001" } as Task);
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([{ id: "FN-002", title: "dup", description: "dup", column: "todo", score: 1 }]);
renderCard([], { onSubmit });
expandCard();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "duplicate" } });
fireEvent.click(screen.getByTestId("save-button"));
expect(await screen.findByText(/Possible duplicates/i)).toBeTruthy();
expect(onSubmit).not.toHaveBeenCalled();
});
it("proceeds with acknowledgedDuplicates from duplicate modal", async () => {
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-001" } as Task);
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([{ id: "FN-002", title: "dup", description: "dup", column: "todo", score: 1 }]);
renderCard([], { onSubmit });
expandCard();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "duplicate" } });
fireEvent.click(screen.getByTestId("save-button"));
const proceed = await screen.findByRole("button", { name: /Create anyway/i });
fireEvent.click(proceed);
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ acknowledgedDuplicates: ["FN-002"] })));
});
it("fails open when duplicate check throws", async () => {
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-001" } as Task);
vi.mocked(checkDuplicateTasks).mockRejectedValueOnce(new Error("boom"));
renderCard([], { onSubmit });
expandCard();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "new task" } });
fireEvent.click(screen.getByTestId("save-button"));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
});
});
describe("InlineCreateCard model selector", () => {
it("clicking Models button opens the ModelSelectionModal", () => {
renderCard();