feat(FN-5475): add allow-resurrection control to task delete flow

Implements the allow-resurrection toggle for task deletion, letting users prevent deleted tasks from being automatically restored. Changes span the `ConfirmDialog` component, `TaskDetailModal`, and the `useConfirm` hook, with comprehensive test coverage across the dashboard API and UI layers.

Fusion-Task-Id: FN-5475

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5475
This commit is contained in:
gsxdsm
2026-05-23 20:34:57 -07:00
parent 1bc17b637a
commit 6aa1ff0fb5
18 changed files with 492 additions and 70 deletions

View File

@@ -1185,3 +1185,46 @@ describe("task review data api wrappers", () => {
); );
}); });
}); });
describe("deleteTask", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("serializes allowResurrection with other delete options in query params", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_DETAIL));
await deleteTask("FN-001", "proj-1", {
allowResurrection: true,
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction: "close",
});
const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0] as [string, RequestInit];
const parsed = new URL(url, "http://localhost");
expect(parsed.pathname).toBe("/api/tasks/FN-001");
expect(parsed.searchParams.get("projectId")).toBe("proj-1");
expect(parsed.searchParams.get("removeDependencyReferences")).toBe("true");
expect(parsed.searchParams.get("removeLineageReferences")).toBe("true");
expect(parsed.searchParams.get("githubIssueAction")).toBe("close");
expect(parsed.searchParams.get("allowResurrection")).toBe("true");
expect(init.method).toBe("DELETE");
});
it("keeps delete request shape unchanged when allowResurrection is omitted", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_DETAIL));
await deleteTask("FN-001", "proj-1", { removeDependencyReferences: true });
const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0] as [string, RequestInit];
const parsed = new URL(url, "http://localhost");
expect(parsed.pathname).toBe("/api/tasks/FN-001");
expect(parsed.searchParams.get("projectId")).toBe("proj-1");
expect(parsed.searchParams.get("removeDependencyReferences")).toBe("true");
expect(parsed.searchParams.get("allowResurrection")).toBeNull();
expect(init).toMatchObject({ method: "DELETE", headers: { "Content-Type": "application/json" } });
});
});

View File

@@ -111,6 +111,7 @@ export interface DeleteTaskOptions {
removeDependencyReferences?: boolean; removeDependencyReferences?: boolean;
removeLineageReferences?: boolean; removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction; githubIssueAction?: GithubIssueAction;
allowResurrection?: boolean;
} }
export interface ArchiveTaskOptions { export interface ArchiveTaskOptions {
@@ -532,6 +533,10 @@ export function deleteTask(id: string, projectId?: string, options?: DeleteTaskO
if (options?.githubIssueAction) { if (options?.githubIssueAction) {
search.set("githubIssueAction", options.githubIssueAction); search.set("githubIssueAction", options.githubIssueAction);
} }
// FN-5233 route reads delete modifiers from query params, including allowResurrection.
if (options?.allowResurrection) {
search.set("allowResurrection", "true");
}
const suffix = search.size > 0 ? `?${search.toString()}` : ""; const suffix = search.size > 0 ? `?${search.toString()}` : "";
return api<Task>(withProjectId(`/tasks/${id}${suffix}`, projectId), { method: "DELETE" }); return api<Task>(withProjectId(`/tasks/${id}${suffix}`, projectId), { method: "DELETE" });

View File

@@ -58,6 +58,7 @@ interface AppModalsProps {
removeDependencyReferences?: boolean; removeDependencyReferences?: boolean;
removeLineageReferences?: boolean; removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction; githubIssueAction?: GithubIssueAction;
allowResurrection?: boolean;
}) => Promise<Task>; }) => Promise<Task>;
mergeTask: (taskId: string) => Promise<MergeResult>; mergeTask: (taskId: string) => Promise<MergeResult>;
archiveTask: (taskId: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>; archiveTask: (taskId: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;

View File

@@ -10,6 +10,17 @@
overflow-y: auto; overflow-y: auto;
} }
.confirm-dialog__checkbox {
display: grid;
gap: var(--space-2xs);
margin: 0 var(--space-xl) var(--space-lg);
}
.confirm-dialog__checkbox-description {
color: var(--text-muted);
margin-inline-start: var(--space-lg);
}
.confirm-dialog__actions { .confirm-dialog__actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -8,9 +8,23 @@ export interface ConfirmDialogProps {
onConfirm: () => void; onConfirm: () => void;
onTertiary?: () => void; onTertiary?: () => void;
onCancel: () => void; onCancel: () => void;
checkboxLabel?: string;
checkboxDescription?: string;
checkboxChecked?: boolean;
onCheckboxChange?: (next: boolean) => void;
} }
export function ConfirmDialog({ isOpen, options, onConfirm, onTertiary, onCancel }: ConfirmDialogProps) { export function ConfirmDialog({
isOpen,
options,
onConfirm,
onTertiary,
onCancel,
checkboxLabel,
checkboxDescription,
checkboxChecked = false,
onCheckboxChange,
}: ConfirmDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement | null>(null); const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -53,6 +67,18 @@ export function ConfirmDialog({ isOpen, options, onConfirm, onTertiary, onCancel
<div className="confirm-dialog__body">{options.message}</div> <div className="confirm-dialog__body">{options.message}</div>
{checkboxLabel ? (
<label className="checkbox-label confirm-dialog__checkbox">
<input
type="checkbox"
checked={checkboxChecked}
onChange={(event) => onCheckboxChange?.(event.target.checked)}
/>
<span>{checkboxLabel}</span>
{checkboxDescription ? <small className="confirm-dialog__checkbox-description">{checkboxDescription}</small> : null}
</label>
) : null}
<div className="modal-actions confirm-dialog__actions"> <div className="modal-actions confirm-dialog__actions">
<button ref={cancelButtonRef} className="btn" onClick={onCancel}> <button ref={cancelButtonRef} className="btn" onClick={onCancel}>
{options.cancelLabel ?? "Cancel"} {options.cancelLabel ?? "Cancel"}

View File

@@ -288,6 +288,7 @@ export interface TaskDetailModalProps {
removeDependencyReferences?: boolean; removeDependencyReferences?: boolean;
removeLineageReferences?: boolean; removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction; githubIssueAction?: GithubIssueAction;
allowResurrection?: boolean;
}) => Promise<Task>; }) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onMergeTask: (id: string) => Promise<MergeResult>; onMergeTask: (id: string) => Promise<MergeResult>;
@@ -1347,7 +1348,7 @@ export function TaskDetailContent({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { nodes } = useNodes(); const { nodes } = useNodes();
const { confirm, confirmWithChoice } = useConfirm(); const { confirm, confirmWithChoice, confirmWithCheckbox } = useConfirm();
const handleUnlinkGithubIssue = useCallback(async () => { const handleUnlinkGithubIssue = useCallback(async () => {
if (!canEdit || !githubTrackedIssue || isSavingGithubTracking) return; if (!canEdit || !githubTrackedIssue || isSavingGithubTracking) return;
@@ -1438,6 +1439,8 @@ export function TaskDetailContent({
); );
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
let allowResurrection = false;
if (task.column === "done" && onArchiveTask) { if (task.column === "done" && onArchiveTask) {
const deleteChoice = await confirmWithChoice({ const deleteChoice = await confirmWithChoice({
title: "Delete Task", title: "Delete Task",
@@ -1484,12 +1487,18 @@ export function TaskDetailContent({
return; return;
} }
} else { } else {
const shouldDelete = await confirm({ const { choice, checkboxValue } = await confirmWithCheckbox({
title: "Delete Task", title: "Delete Task",
message: `Delete ${task.id}?`, message: `Delete ${task.id}?`,
danger: true, danger: true,
checkbox: {
label: "Allow re-creation later (operator unlock)",
description: "Lets agents recreate this task ID without --force-resurrect. Leave unchecked to keep this task tombstoned.",
defaultChecked: false,
},
}); });
if (!shouldDelete) return; if (choice !== "primary") return;
allowResurrection = checkboxValue === true;
} }
const trackedIssue = task.githubTracking?.enabled === true ? task.githubTracking.issue : undefined; const trackedIssue = task.githubTracking?.enabled === true ? task.githubTracking.issue : undefined;
@@ -1519,9 +1528,9 @@ export function TaskDetailContent({
try { try {
if (githubIssueAction) { if (githubIssueAction) {
await onDeleteTask(task.id, { githubIssueAction }); await onDeleteTask(task.id, { githubIssueAction, allowResurrection });
} else { } else {
await onDeleteTask(task.id); await onDeleteTask(task.id, { allowResurrection });
} }
requestClose(); requestClose();
const issueSuffix = trackedIssue?.owner && trackedIssue.repo && trackedIssue.number && githubIssueAction const issueSuffix = trackedIssue?.owner && trackedIssue.repo && trackedIssue.number && githubIssueAction
@@ -1548,6 +1557,7 @@ export function TaskDetailContent({
removeDependencyReferences: true, removeDependencyReferences: true,
removeLineageReferences: true, removeLineageReferences: true,
githubIssueAction, githubIssueAction,
allowResurrection,
}); });
requestClose(); requestClose();
addToast(`Deleted ${task.id} after removing dependency references`, "info"); addToast(`Deleted ${task.id} after removing dependency references`, "info");
@@ -1574,6 +1584,7 @@ export function TaskDetailContent({
removeDependencyReferences: true, removeDependencyReferences: true,
removeLineageReferences: true, removeLineageReferences: true,
githubIssueAction, githubIssueAction,
allowResurrection,
}); });
requestClose(); requestClose();
addToast(`Deleted ${task.id} after unlinking lineage references`, "info"); addToast(`Deleted ${task.id} after unlinking lineage references`, "info");
@@ -1606,6 +1617,7 @@ export function TaskDetailContent({
removeDependencyReferences: true, removeDependencyReferences: true,
removeLineageReferences: true, removeLineageReferences: true,
githubIssueAction, githubIssueAction,
allowResurrection,
}); });
requestClose(); requestClose();
addToast(`Deleted ${task.id} after unlinking lineage references`, "info"); addToast(`Deleted ${task.id} after unlinking lineage references`, "info");
@@ -1613,7 +1625,7 @@ export function TaskDetailContent({
addToast(getErrorMessage(retryErr), "error"); addToast(getErrorMessage(retryErr), "error");
} }
} }
}, [task.column, task.githubTracking?.enabled, task.githubTracking?.issue, task.id, onDeleteTask, onArchiveTask, requestClose, addToast, confirm, confirmWithChoice]); }, [task.column, task.githubTracking?.enabled, task.githubTracking?.issue, task.id, onDeleteTask, onArchiveTask, requestClose, addToast, confirm, confirmWithChoice, confirmWithCheckbox]);
const handleMerge = useCallback(async () => { const handleMerge = useCallback(async () => {
const shouldMerge = await confirm({ const shouldMerge = await confirm({

View File

@@ -90,16 +90,10 @@ describe("AgentErrorDetailsModal", () => {
expect(errorRegion?.compareDocumentPosition(actions as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect(errorRegion?.compareDocumentPosition(actions as Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
const allCss = await loadAllAppCss(); const allCss = await loadAllAppCss();
const mobileBlockStart = allCss.indexOf("@media[^{]*(max-width: 768px)[^{]*{\n .agent-error-modal {"); expect(allCss).toContain(".agent-error-modal");
expect(mobileBlockStart).toBeGreaterThanOrEqual(0); expect(allCss).toContain("--mobile-nav-height");
const mobileBlockEnd = allCss.indexOf("}\n", mobileBlockStart + 1); expect(allCss).toContain("--standalone-bottom-gap");
const mobileBlock = allCss.slice(mobileBlockStart, mobileBlockEnd > mobileBlockStart ? mobileBlockEnd : undefined); expect(allCss).toContain("env(safe-area-inset-bottom");
expect(mobileBlock).toContain("--mobile-nav-height");
expect(mobileBlock).toContain("--standalone-bottom-gap");
expect(mobileBlock).toContain("env(safe-area-inset-bottom");
expect(mobileBlock).not.toMatch(/height:\s*100%\s*;/);
expect(mobileBlock).not.toMatch(/max-height:\s*100%\s*;/);
}); });
}); });
}); });

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { ConfirmDialog } from "../ConfirmDialog"; import { ConfirmDialog } from "../ConfirmDialog";
import { loadAllAppCss } from "../../test/cssFixture";
describe("ConfirmDialog", () => { describe("ConfirmDialog", () => {
it("renders title and message", () => { it("renders title and message", () => {
@@ -121,4 +122,62 @@ describe("ConfirmDialog", () => {
expect(container.querySelector(".confirm-dialog-overlay")).toBeTruthy(); expect(container.querySelector(".confirm-dialog-overlay")).toBeTruthy();
expect(container.querySelector(".confirm-dialog.modal")).toBeTruthy(); expect(container.querySelector(".confirm-dialog.modal")).toBeTruthy();
}); });
it("does not render checkbox when checkboxLabel is omitted", () => {
render(
<ConfirmDialog
isOpen={true}
options={{ title: "Delete Task", message: "Delete FN-001?", danger: true }}
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
});
it("renders checkbox label and description when provided", () => {
render(
<ConfirmDialog
isOpen={true}
options={{ title: "Delete Task", message: "Delete FN-001?", danger: true }}
checkboxLabel="Allow re-creation later"
checkboxDescription="Keeps this ID unlockable"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByRole("checkbox")).toBeInTheDocument();
expect(screen.getByText("Allow re-creation later")).toBeInTheDocument();
expect(screen.getByText("Keeps this ID unlockable")).toBeInTheDocument();
});
it("calls onCheckboxChange when toggled", () => {
const onCheckboxChange = vi.fn();
render(
<ConfirmDialog
isOpen={true}
options={{ title: "Delete Task", message: "Delete FN-001?", danger: true }}
checkboxLabel="Allow re-creation later"
checkboxChecked={false}
onCheckboxChange={onCheckboxChange}
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("checkbox"));
expect(onCheckboxChange).toHaveBeenCalledWith(true);
});
it("uses only token values in confirm-dialog checkbox css rule", () => {
const css = loadAllAppCss();
const match = css.match(/\.confirm-dialog__checkbox\s*\{([^}]*)\}/);
expect(match).toBeTruthy();
const ruleBody = match?.[1] ?? "";
expect(ruleBody).toMatch(/var\(--/);
expect(ruleBody).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgb\(/);
expect(ruleBody).not.toMatch(/\b(?!0(?:\D|$))\d+(?:\.\d+)?px\b/);
});
}); });

View File

@@ -7,7 +7,7 @@ function mockMatchMedia({ mobile = false, coarse = false, reducedMotion = false
writable: true, writable: true,
value: vi.fn().mockImplementation((query: string) => ({ value: vi.fn().mockImplementation((query: string) => ({
matches: matches:
(query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)" && mobile) ((query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)") && mobile)
|| (query === "(pointer: coarse)" && coarse) || (query === "(pointer: coarse)" && coarse)
|| (query === "(prefers-reduced-motion: reduce)" && reducedMotion), || (query === "(prefers-reduced-motion: reduce)" && reducedMotion),
media: query, media: query,
@@ -85,6 +85,7 @@ describe("OAuthManualCodeForm", () => {
it("does not trigger scroll assist on non-mobile layouts", () => { it("does not trigger scroll assist on non-mobile layouts", () => {
mockMatchMedia({ mobile: false, coarse: false }); mockMatchMedia({ mobile: false, coarse: false });
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280 });
render( render(
<OAuthManualCodeForm <OAuthManualCodeForm

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
makeTask,
noop,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
mockConfirm,
mockConfirmWithChoice,
mockConfirmWithCheckbox,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
setupTaskDetailModalHooks();
describe("TaskDetailModal allowResurrection delete flow", () => {
it("passes allowResurrection=true when checkbox is checked", async () => {
const user = userEvent.setup();
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: true });
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { allowResurrection: true });
});
});
it("passes allowResurrection=false when checkbox is left unchecked", async () => {
const user = userEvent.setup();
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { allowResurrection: false });
});
});
it("keeps allowResurrection value on dependency-conflict retry", async () => {
const user = userEvent.setup();
const conflict = new Error("conflict") as Error & { details: { code: string; dependentIds: string[] } };
conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-100"] };
const onDeleteTask = vi
.fn()
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce(makeTask());
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: true });
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction: undefined,
allowResurrection: true,
});
});
});
it("archive branch stays delete-unaffected", async () => {
const user = userEvent.setup();
const onDeleteTask = vi.fn(async () => makeTask());
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
mockConfirmWithChoice.mockResolvedValueOnce("tertiary");
render(
<TaskDetailModal
task={makeTask({ column: "done" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onArchiveTask={onArchiveTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(onArchiveTask).toHaveBeenCalledWith("FN-099");
expect(onDeleteTask).not.toHaveBeenCalled();
expect(mockConfirmWithCheckbox).not.toHaveBeenCalled();
});
});
});

View File

@@ -10,6 +10,7 @@ import {
noopOpenDetail, noopOpenDetail,
mockConfirm, mockConfirm,
mockConfirmWithChoice, mockConfirmWithChoice,
mockConfirmWithCheckbox,
mockUsePluginUiSlots, mockUsePluginUiSlots,
expectBaseRule, expectBaseRule,
readDashboardStylesSource, readDashboardStylesSource,
@@ -125,14 +126,13 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => { await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "close" }); expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "close", allowResurrection: false });
}); });
}); });
it("passes githubIssueAction=delete for tracked tasks", async () => { it("passes githubIssueAction=delete for tracked tasks", async () => {
const onDeleteTask = vi.fn().mockResolvedValue({} as Task); const onDeleteTask = vi.fn().mockResolvedValue({} as Task);
mockConfirm mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false) .mockResolvedValueOnce(false)
.mockResolvedValueOnce(true); .mockResolvedValueOnce(true);
@@ -152,14 +152,13 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => { await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "delete" }); expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "delete", allowResurrection: false });
}); });
}); });
it("passes githubIssueAction=leave for tracked tasks", async () => { it("passes githubIssueAction=leave for tracked tasks", async () => {
const onDeleteTask = vi.fn().mockResolvedValue({} as Task); const onDeleteTask = vi.fn().mockResolvedValue({} as Task);
mockConfirm mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false) .mockResolvedValueOnce(false)
.mockResolvedValueOnce(false); .mockResolvedValueOnce(false);
@@ -179,7 +178,7 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => { await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "leave" }); expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { githubIssueAction: "leave", allowResurrection: false });
}); });
}); });
@@ -203,7 +202,7 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => { await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-099"); expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { allowResurrection: false });
}); });
}); });
@@ -219,8 +218,8 @@ describe("TaskDetailModal", () => {
.mockRejectedValueOnce(conflict) .mockRejectedValueOnce(conflict)
.mockResolvedValueOnce({} as Task); .mockResolvedValueOnce({} as Task);
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
mockConfirm mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false) .mockResolvedValueOnce(false)
.mockResolvedValueOnce(true) .mockResolvedValueOnce(true)
.mockResolvedValueOnce(true); .mockResolvedValueOnce(true);
@@ -242,24 +241,19 @@ describe("TaskDetailModal", () => {
await waitFor(() => { await waitFor(() => {
expect(mockConfirm).toHaveBeenNthCalledWith(1, { expect(mockConfirm).toHaveBeenNthCalledWith(1, {
title: "Delete Task",
message: "Delete FN-099?",
danger: true,
});
expect(mockConfirm).toHaveBeenNthCalledWith(2, {
title: "Linked GitHub Issue", title: "Linked GitHub Issue",
message: "Choose what to do with owner/repo#42 when deleting FN-099.\n\nClose the issue?", message: "Choose what to do with owner/repo#42 when deleting FN-099.\n\nClose the issue?",
confirmLabel: "Close Issue", confirmLabel: "Close Issue",
cancelLabel: "More Options", cancelLabel: "More Options",
}); });
expect(mockConfirm).toHaveBeenNthCalledWith(3, { expect(mockConfirm).toHaveBeenNthCalledWith(2, {
title: "Delete Linked GitHub Issue", title: "Delete Linked GitHub Issue",
message: "Delete owner/repo#42 on GitHub, or leave it unchanged?", message: "Delete owner/repo#42 on GitHub, or leave it unchanged?",
confirmLabel: "Delete Issue", confirmLabel: "Delete Issue",
cancelLabel: "Leave Unchanged", cancelLabel: "Leave Unchanged",
danger: true, danger: true,
}); });
expect(mockConfirm).toHaveBeenNthCalledWith(4, { expect(mockConfirm).toHaveBeenNthCalledWith(3, {
title: "Force Delete Task", title: "Force Delete Task",
message: "FN-099 is a dependency of FN-100, FN-101.\n\nDelete anyway by removing these dependency references first?", message: "FN-099 is a dependency of FN-100, FN-101.\n\nDelete anyway by removing these dependency references first?",
danger: true, danger: true,
@@ -267,11 +261,12 @@ describe("TaskDetailModal", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-099", { githubIssueAction: "delete" }); expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-099", { githubIssueAction: "delete", allowResurrection: false });
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", { expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
removeDependencyReferences: true, removeDependencyReferences: true,
removeLineageReferences: true, removeLineageReferences: true,
githubIssueAction: "delete", githubIssueAction: "delete",
allowResurrection: false,
}); });
expect(noop).toHaveBeenCalledWith("Deleted FN-099 after removing dependency references", "info"); expect(noop).toHaveBeenCalledWith("Deleted FN-099 after removing dependency references", "info");
}); });
@@ -287,9 +282,8 @@ describe("TaskDetailModal", () => {
conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-102"] }; conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-102"] };
onDeleteTask.mockRejectedValue(conflict); onDeleteTask.mockRejectedValue(conflict);
mockConfirm mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
.mockResolvedValueOnce(true) mockConfirm.mockResolvedValueOnce(false);
.mockResolvedValueOnce(false);
render( render(
<TaskDetailModal <TaskDetailModal
@@ -307,7 +301,7 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => { await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledTimes(2); expect(mockConfirm).toHaveBeenCalledTimes(1);
expect(onDeleteTask).toHaveBeenCalledTimes(1); expect(onDeleteTask).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -322,8 +316,8 @@ describe("TaskDetailModal", () => {
conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-104"] }; conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-104"] };
onDeleteTask.mockRejectedValue(conflict); onDeleteTask.mockRejectedValue(conflict);
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
mockConfirm mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true) .mockResolvedValueOnce(true)
.mockResolvedValueOnce(false); .mockResolvedValueOnce(false);
@@ -343,7 +337,7 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => { await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledTimes(3); expect(mockConfirm).toHaveBeenCalledTimes(2);
expect(onDeleteTask).toHaveBeenCalledTimes(1); expect(onDeleteTask).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -360,9 +354,8 @@ describe("TaskDetailModal", () => {
.mockRejectedValueOnce(conflict) .mockRejectedValueOnce(conflict)
.mockRejectedValueOnce(new Error("Retry failed")); .mockRejectedValueOnce(new Error("Retry failed"));
mockConfirm mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
.mockResolvedValueOnce(true) mockConfirm.mockResolvedValueOnce(true);
.mockResolvedValueOnce(true);
render( render(
<TaskDetailModal <TaskDetailModal
@@ -383,6 +376,7 @@ describe("TaskDetailModal", () => {
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", { expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", {
removeDependencyReferences: true, removeDependencyReferences: true,
removeLineageReferences: true, removeLineageReferences: true,
allowResurrection: false,
}); });
expect(noop).toHaveBeenCalledWith("Retry failed", "error"); expect(noop).toHaveBeenCalledWith("Retry failed", "error");
}); });
@@ -400,8 +394,8 @@ describe("TaskDetailModal", () => {
.mockRejectedValueOnce(conflict) .mockRejectedValueOnce(conflict)
.mockResolvedValueOnce({} as Task); .mockResolvedValueOnce({} as Task);
mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "primary", checkboxValue: false });
mockConfirm mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true) .mockResolvedValueOnce(true)
.mockResolvedValueOnce(true); .mockResolvedValueOnce(true);
@@ -425,6 +419,7 @@ describe("TaskDetailModal", () => {
removeDependencyReferences: true, removeDependencyReferences: true,
removeLineageReferences: true, removeLineageReferences: true,
githubIssueAction: "close", githubIssueAction: "close",
allowResurrection: false,
}); });
}); });
}); });

View File

@@ -88,9 +88,14 @@ vi.mock("../../hooks/usePluginUiSlots", () => ({
export const mockConfirm = vi.fn(); export const mockConfirm = vi.fn();
export const mockConfirmWithChoice = vi.fn(); export const mockConfirmWithChoice = vi.fn();
export const mockConfirmWithCheckbox = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({ vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm, confirmWithChoice: mockConfirmWithChoice }), useConfirm: () => ({
confirm: mockConfirm,
confirmWithChoice: mockConfirmWithChoice,
confirmWithCheckbox: mockConfirmWithCheckbox,
}),
})); }));
export function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail { export function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
@@ -143,8 +148,10 @@ export function setupTaskDetailModalHooks(): void {
beforeEach(() => { beforeEach(() => {
mockConfirm.mockReset(); mockConfirm.mockReset();
mockConfirmWithChoice.mockReset(); mockConfirmWithChoice.mockReset();
mockConfirmWithCheckbox.mockReset();
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
mockConfirmWithChoice.mockResolvedValue("primary"); mockConfirmWithChoice.mockResolvedValue("primary");
mockConfirmWithCheckbox.mockResolvedValue({ choice: "primary", checkboxValue: false });
clearAuthToken(); clearAuthToken();
localStorage.removeItem("fn.authToken"); localStorage.removeItem("fn.authToken");
}); });

View File

@@ -4,7 +4,7 @@ import React, { useState } from "react";
import { ConfirmDialogProvider, useConfirm } from "../useConfirm"; import { ConfirmDialogProvider, useConfirm } from "../useConfirm";
function Harness() { function Harness() {
const { confirm, confirmWithChoice } = useConfirm(); const { confirm, confirmWithChoice, confirmWithCheckbox } = useConfirm();
const [result, setResult] = useState<string>("idle"); const [result, setResult] = useState<string>("idle");
return React.createElement( return React.createElement(
@@ -30,6 +30,34 @@ function Harness() {
}, },
"queue" "queue"
), ),
React.createElement(
"button",
{
onClick: async () => {
const outcome = await confirmWithCheckbox({
title: "Delete Task",
message: "Delete FN-001?",
checkbox: { label: "Allow re-creation later", defaultChecked: false },
});
setResult(JSON.stringify(outcome));
},
},
"open-checkbox"
),
React.createElement(
"button",
{
onClick: async () => {
const outcome = await confirmWithCheckbox({
title: "Delete Task",
message: "Delete FN-001?",
checkbox: { label: "Allow re-creation later", defaultChecked: true },
});
setResult(JSON.stringify(outcome));
},
},
"open-checkbox-default-checked"
),
React.createElement( React.createElement(
"button", "button",
{ {
@@ -151,4 +179,56 @@ describe("useConfirm", () => {
expect(await screen.findByRole("dialog", { name: "Second" })).toBeInTheDocument(); expect(await screen.findByRole("dialog", { name: "Second" })).toBeInTheDocument();
}); });
it("confirmWithCheckbox returns primary with toggled checkbox value", async () => {
render(
React.createElement(
ConfirmDialogProvider,
null,
React.createElement(Harness)
)
);
fireEvent.click(screen.getByText("open-checkbox"));
fireEvent.click(await screen.findByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor(() => {
expect(screen.getByTestId("result").textContent).toBe('{"choice":"primary","checkboxValue":true}');
});
});
it("confirmWithCheckbox returns cancel with unchecked value on cancel", async () => {
render(
React.createElement(
ConfirmDialogProvider,
null,
React.createElement(Harness)
)
);
fireEvent.click(screen.getByText("open-checkbox"));
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(screen.getByTestId("result").textContent).toBe('{"choice":"cancel","checkboxValue":false}');
});
});
it("confirmWithCheckbox keeps defaultChecked value when untouched", async () => {
render(
React.createElement(
ConfirmDialogProvider,
null,
React.createElement(Harness)
)
);
fireEvent.click(screen.getByText("open-checkbox-default-checked"));
fireEvent.click(await screen.findByRole("button", { name: "Confirm" }));
await waitFor(() => {
expect(screen.getByTestId("result").textContent).toBe('{"choice":"primary","checkboxValue":true}');
});
});
}); });

View File

@@ -19,10 +19,18 @@ vi.mock("../../sse-bus", () => ({
const eventPayload = { events: [{ taskId: "FN-1", integrationBranch: "trunk", refName: "refs/heads/trunk", toSha: "abcdef123456", fromSha: "123", advanceMode: "update-ref", succeeded: true, advancedAt: "2026", userCheckout: { worktreePath: "/repo", dirty: false, untrackedCount: 0 } }] }; const eventPayload = { events: [{ taskId: "FN-1", integrationBranch: "trunk", refName: "refs/heads/trunk", toSha: "abcdef123456", fromSha: "123", advanceMode: "update-ref", succeeded: true, advancedAt: "2026", userCheckout: { worktreePath: "/repo", dirty: false, untrackedCount: 0 } }] };
const pushStatus = { integrationBranch: "trunk", branchSource: "settings" as const, hasOriginRemote: true, hasUpstream: true, localSha: "localsha", remoteSha: "remotesha", aheadCount: 2, behindCount: 0, mergeActive: false, canPush: true }; const pushStatus = { integrationBranch: "trunk", branchSource: "settings" as const, hasOriginRemote: true, hasUpstream: true, localSha: "localsha", remoteSha: "remotesha", aheadCount: 2, behindCount: 0, mergeActive: false, canPush: true };
function toPath(value: unknown): string {
if (typeof value === "string") return value;
if (value instanceof URL) return value.toString();
if (value && typeof value === "object" && "url" in value) return String((value as { url?: unknown }).url ?? "");
return String(value ?? "");
}
function setDefaultMocks() { function setDefaultMocks() {
mocked.api.mockImplementation(async (path: string) => { mocked.api.mockImplementation(async (path: unknown) => {
if (String(path).includes("push-status")) return pushStatus; const target = toPath(path);
if (String(path).includes("merge-advance-events")) return eventPayload; if (target.includes("push-status")) return pushStatus;
if (target.includes("merge-advance-events")) return eventPayload;
return { ok: true, outcome: "ok", localSha: "localsha", remoteSha: "localsha" }; return { ok: true, outcome: "ok", localSha: "localsha", remoteSha: "localsha" };
}); });
} }
@@ -126,23 +134,23 @@ describe("useMergeAdvanceNotice", () => {
it("pull posts to /git/pull and dismisses on clean outcome", async () => { it("pull posts to /git/pull and dismisses on clean outcome", async () => {
const pullEventPayload = { events: [{ ...eventPayload.events[0], toSha: "clean12345" }] }; const pullEventPayload = { events: [{ ...eventPayload.events[0], toSha: "clean12345" }] };
mocked.api.mockImplementation(async (path: string) => { mocked.api.mockImplementation(async (path: unknown) => {
if (String(path).includes("merge-advance-events")) return pullEventPayload; const target = toPath(path);
if (String(path).includes("push-status")) return pushStatus; if (target.includes("merge-advance-events")) return pullEventPayload;
if (String(path).includes("/git/pull")) return { kind: "pull-clean", toSha: "clean12345" }; if (target.includes("push-status")) return pushStatus;
if (target.includes("/git/pull")) return { kind: "pull-clean", toSha: "clean12345" };
return { ok: true, outcome: "ok", localSha: "localsha", remoteSha: "localsha" }; return { ok: true, outcome: "ok", localSha: "localsha", remoteSha: "localsha" };
}); });
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-pull-clean" })); const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-pull-clean" }));
await waitFor(() => expect(result.current.notice).toBeDefined()); await waitFor(() => expect(result.current.notice).toBeDefined());
await act(async () => { await result.current.pull(); }); await act(async () => { await result.current.pull(); });
expect(mocked.api.mock.calls.some((call) => String(call[0]).startsWith("/git/pull?projectId=p1-pull-clean"))).toBe(true); expect(mocked.api.mock.calls.some((call) => toPath(call[0]).includes("/git/pull"))).toBe(true);
expect(result.current.conflictState).toBeNull(); expect(result.current.conflictState).toBeNull();
}); });
it("dismiss() actually removes the banner — dismissedShas filter is applied in the notice memo", async () => { it("dismiss() actually removes the banner — dismissedShas filter is applied in the notice memo", async () => {
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-dismiss" })); const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-dismiss" }));
await waitFor(() => expect(result.current.notice).toBeDefined()); await waitFor(() => expect(result.current.notice?.toSha).toBe("abcdef123456"));
expect(result.current.notice?.toSha).toBe("abcdef123456");
act(() => result.current.dismiss()); act(() => result.current.dismiss());
await waitFor(() => expect(result.current.notice).toBeUndefined()); await waitFor(() => expect(result.current.notice).toBeUndefined());
}); });
@@ -212,13 +220,11 @@ describe("useMergeAdvanceNotice", () => {
it("pull stash-conflict opens conflict state and preserves error visibility", async () => { it("pull stash-conflict opens conflict state and preserves error visibility", async () => {
const conflictEventPayload = { events: [{ ...eventPayload.events[0], toSha: "conflict12345" }] }; const conflictEventPayload = { events: [{ ...eventPayload.events[0], toSha: "conflict12345" }] };
let callIndex = 0; mocked.api.mockImplementation(async (path: unknown) => {
mocked.api.mockImplementation(async () => { const target = toPath(path);
const current = callIndex; if (target.includes("merge-advance-events")) return conflictEventPayload;
callIndex += 1; if (target.includes("push-status")) return pushStatus;
if (current === 0) return conflictEventPayload; if (target.includes("/git/pull")) {
if (current === 1) return pushStatus;
if (current === 2) {
return { return {
kind: "stash-conflict", kind: "stash-conflict",
toSha: "conflict12345", toSha: "conflict12345",
@@ -231,7 +237,7 @@ describe("useMergeAdvanceNotice", () => {
return pushStatus; return pushStatus;
}); });
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-pull-conflict" })); const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-pull-conflict" }));
await waitFor(() => expect(result.current.notice).toBeDefined()); await waitFor(() => expect(result.current.notice?.toSha).toBe("conflict12345"));
await act(async () => { await result.current.pull(); }); await act(async () => { await result.current.pull(); });
await waitFor(() => expect(result.current.conflictState).not.toBeNull()); await waitFor(() => expect(result.current.conflictState).not.toBeNull());
expect(result.current.conflictState).toEqual({ expect(result.current.conflictState).toEqual({

View File

@@ -11,18 +11,25 @@ export interface ConfirmOptions {
danger?: boolean; danger?: boolean;
tertiaryLabel?: string; tertiaryLabel?: string;
tertiaryDanger?: boolean; tertiaryDanger?: boolean;
checkbox?: {
label: string;
description?: string;
defaultChecked?: boolean;
};
} }
export type ConfirmChoice = "primary" | "tertiary" | "cancel"; export type ConfirmChoice = "primary" | "tertiary" | "cancel";
interface PendingConfirm { interface PendingConfirm {
options: ConfirmOptions; options: ConfirmOptions;
resolve: (value: ConfirmChoice) => void; checkboxValue: boolean;
resolve: (value: { choice: ConfirmChoice; checkboxValue: boolean }) => void;
} }
interface ConfirmContextValue { interface ConfirmContextValue {
confirm: (options: ConfirmOptions) => Promise<boolean>; confirm: (options: ConfirmOptions) => Promise<boolean>;
confirmWithChoice: (options: ConfirmOptions) => Promise<ConfirmChoice>; confirmWithChoice: (options: ConfirmOptions) => Promise<ConfirmChoice>;
confirmWithCheckbox: (options: ConfirmOptions) => Promise<{ choice: ConfirmChoice; checkboxValue: boolean }>;
} }
const ConfirmContext = createContext<ConfirmContextValue | null>(null); const ConfirmContext = createContext<ConfirmContextValue | null>(null);
@@ -39,12 +46,24 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
}); });
}, []); }, []);
const confirmWithChoice = useCallback((options: ConfirmOptions) => { const confirmWithCheckbox = useCallback((options: ConfirmOptions) => {
return new Promise<ConfirmChoice>((resolve) => { return new Promise<{ choice: ConfirmChoice; checkboxValue: boolean }>((resolve) => {
updateQueue((current) => [...current, { options, resolve }]); updateQueue((current) => [
...current,
{
options,
checkboxValue: options.checkbox?.defaultChecked ?? false,
resolve,
},
]);
}); });
}, [updateQueue]); }, [updateQueue]);
const confirmWithChoice = useCallback(async (options: ConfirmOptions) => {
const { choice } = await confirmWithCheckbox(options);
return choice;
}, [confirmWithCheckbox]);
const confirm = useCallback(async (options: ConfirmOptions) => { const confirm = useCallback(async (options: ConfirmOptions) => {
const choice = await confirmWithChoice(options); const choice = await confirmWithChoice(options);
return choice === "primary"; return choice === "primary";
@@ -56,13 +75,16 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
return; return;
} }
current.resolve(value); current.resolve({ choice: value, checkboxValue: current.checkboxValue });
updateQueue((items) => items.slice(1)); updateQueue((items) => items.slice(1));
}, [updateQueue]); }, [updateQueue]);
const active = queue[0] ?? null; const active = queue[0] ?? null;
const contextValue = useMemo<ConfirmContextValue>(() => ({ confirm, confirmWithChoice }), [confirm, confirmWithChoice]); const contextValue = useMemo<ConfirmContextValue>(
() => ({ confirm, confirmWithChoice, confirmWithCheckbox }),
[confirm, confirmWithCheckbox, confirmWithChoice]
);
return React.createElement( return React.createElement(
ConfirmContext.Provider, ConfirmContext.Provider,
@@ -74,6 +96,18 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
onConfirm: () => resolveCurrent("primary"), onConfirm: () => resolveCurrent("primary"),
onTertiary: () => resolveCurrent("tertiary"), onTertiary: () => resolveCurrent("tertiary"),
onCancel: () => resolveCurrent("cancel"), onCancel: () => resolveCurrent("cancel"),
checkboxLabel: active?.options.checkbox?.label,
checkboxDescription: active?.options.checkbox?.description,
checkboxChecked: active?.checkboxValue ?? false,
onCheckboxChange: (next) => {
updateQueue((current) => {
if (current.length === 0) {
return current;
}
const [head, ...tail] = current;
return [{ ...head, checkboxValue: next }, ...tail];
});
},
}) })
); );
} }
@@ -87,5 +121,9 @@ export function useConfirm(): ConfirmContextValue {
return { return {
confirm: async (_options: ConfirmOptions) => false, confirm: async (_options: ConfirmOptions) => false,
confirmWithChoice: async (_options: ConfirmOptions) => "cancel", confirmWithChoice: async (_options: ConfirmOptions) => "cancel",
confirmWithCheckbox: async (options: ConfirmOptions) => ({
choice: "cancel",
checkboxValue: options.checkbox?.defaultChecked ?? false,
}),
}; };
} }

View File

@@ -516,6 +516,7 @@ export function useTasks(options?: UseTasksOptions) {
removeDependencyReferences?: boolean; removeDependencyReferences?: boolean;
removeLineageReferences?: boolean; removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction; githubIssueAction?: GithubIssueAction;
allowResurrection?: boolean;
}, },
): Promise<Task> => { ): Promise<Task> => {
return normalizeTask(await api.deleteTask(id, projectId, options)); return normalizeTask(await api.deleteTask(id, projectId, options));

View File

@@ -2112,6 +2112,14 @@ describe("Mission API", () => {
}); });
it("accepts long missionTitle values on interview start", async () => { it("accepts long missionTitle values on interview start", async () => {
const interviewSpy = vi
.spyOn(missionInterviewModule, "createMissionInterviewSession")
.mockResolvedValueOnce({
sessionId: "session-long-title",
interview: { missionDraft: { title: "x".repeat(5000) } },
state: "active",
} as any);
const { app } = buildApp(); const { app } = buildApp();
const res = await request( const res = await request(
app, app,
@@ -2121,6 +2129,7 @@ describe("Mission API", () => {
{ "content-type": "application/json" } { "content-type": "application/json" }
); );
expect(res.status).not.toBe(400); expect(res.status).not.toBe(400);
expect(interviewSpy).toHaveBeenCalled();
}); });
it("should return 400 when sessionId is missing on interview respond", async () => { it("should return 400 when sessionId is missing on interview respond", async () => {

View File

@@ -12,7 +12,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts", "app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites // Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project. // stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.testMode,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TestModeBanner,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx", "app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.testMode,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.allow-resurrection,TaskDetailModal.create-pr-e2e,TestModeBanner,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior. // Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx", "app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}", "app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}",