FN-6914: preserve quick-add drafts for planning
Keep quick-add and inline-create drafts intact while planning is open, and clear them only after planning creates tasks. - Stop resetting QuickEntryBox and InlineCreateCard state when handing text to planning mode. - Clear scoped quick-entry and inline-create drafts after single-task or multi-task planning completion. - Update component and modal-manager tests for preserved drafts, retained options, and completion cleanup. Files changed: packages/dashboard/app/components/InlineCreateCard.tsx | 25 ++------ packages/dashboard/app/components/QuickEntryBox.tsx | 8 ++- .../components/__tests__/InlineCreateCard.test.tsx | 9 +-- .../components/__tests__/QuickEntryBox.test.tsx | 73 +++++++++++++++------- .../app/hooks/__tests__/useModalManager.test.ts | 53 +++++++++++++++- packages/dashboard/app/hooks/useModalManager.ts | 16 ++++- 6 files changed, 131 insertions(+), 53 deletions(-) Fusion-Task-Id: FN-6914 Fusion-Task-Lineage: f7241ae3-658d-48d4-b730-cd3169aec8c8
This commit is contained in:
@@ -700,26 +700,11 @@ export function InlineCreateCard({
|
||||
} else {
|
||||
onPlanningMode?.(trimmed);
|
||||
}
|
||||
// Clear the input after triggering planning mode
|
||||
setDescription("");
|
||||
setSelectedWorkflowId(null);
|
||||
setDependencies([]);
|
||||
setExecutorProvider(undefined);
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setPlanningProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
setEnabledOptionalStepIds([]);
|
||||
setSelectedPresetId(undefined);
|
||||
setSelectedAgentId(null);
|
||||
setNodeId(undefined);
|
||||
setShowDeps(false);
|
||||
setShowAgentPicker(false);
|
||||
setIsModelModalOpen(false);
|
||||
setShowPresets(false);
|
||||
setIsExpanded(false);
|
||||
}, [description, onPlanningMode, selectedWorkflowId, addToast]);
|
||||
/*
|
||||
FNXC:QuickAddPlanningPreserve 2026-06-22-00:00:
|
||||
Opening planning mode must keep the inline-create description and scoped draft available when the user exits without creating tasks. Planning completion owns the eventual draft clear.
|
||||
*/
|
||||
}, [description, onPlanningMode, selectedWorkflowId, addToast, t]);
|
||||
|
||||
const handleSubtaskClick = useCallback(() => {
|
||||
const trimmed = description.trim();
|
||||
|
||||
@@ -1368,9 +1368,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
} else {
|
||||
onPlanningMode?.(trimmed);
|
||||
}
|
||||
// Clear the form after triggering planning mode
|
||||
resetForm();
|
||||
}, [description, onPlanningMode, workflowId, addToast, resetForm]);
|
||||
/*
|
||||
FNXC:QuickAddPlanningPreserve 2026-06-22-00:00:
|
||||
Opening planning mode must preserve the quick-add description and scoped draft so exiting planning without creating tasks restores the user's text. The draft is cleared only by planning-completion handlers.
|
||||
*/
|
||||
}, [description, onPlanningMode, workflowId, addToast, t]);
|
||||
|
||||
const handleSubtaskClick = useCallback(() => {
|
||||
const trimmed = description.trim();
|
||||
|
||||
@@ -834,17 +834,18 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
expect(subtaskButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("calls onPlanningMode with description and clears input when Plan clicked", () => {
|
||||
it("calls onPlanningMode with description and preserves input draft when Plan clicked", () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
renderCard([], { onPlanningMode });
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Plan this task" } });
|
||||
fireEvent.change(textarea, { target: { value: " Plan this task " } });
|
||||
fireEvent.click(screen.getByTestId("plan-button"));
|
||||
|
||||
expect(onPlanningMode).toHaveBeenCalledWith("Plan this task");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
expect(textarea.value).toBe(" Plan this task ");
|
||||
expect(localStorage.getItem(INLINE_CREATE_STORAGE_KEY)).toBe(" Plan this task ");
|
||||
});
|
||||
|
||||
it("calls onSubtaskBreakdown with description and clears input when Subtask clicked", () => {
|
||||
|
||||
@@ -1948,22 +1948,36 @@ describe("QuickEntryBox", () => {
|
||||
expect(secondPayload.executionMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "Plan", buttonId: "plan-button", callbackProp: "onPlanningMode" as const },
|
||||
{ label: "Subtask", buttonId: "subtask-button", callbackProp: "onSubtaskBreakdown" as const },
|
||||
])("clears Fast state after %s flow reset", async ({ buttonId, callbackProp }) => {
|
||||
it("keeps Fast state after Plan handoff preserves the quick-add draft", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderQuickEntryBox({ onPlanningMode, onSubtaskBreakdown });
|
||||
renderQuickEntryBox({ onPlanningMode });
|
||||
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.click(screen.getByTestId("quick-entry-fast-toggle"));
|
||||
fireEvent.change(textarea, { target: { value: `${callbackProp} input` } });
|
||||
fireEvent.click(screen.getByTestId(buttonId));
|
||||
fireEvent.change(textarea, { target: { value: "plan input" } });
|
||||
fireEvent.click(screen.getByTestId("plan-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(callbackProp === "onPlanningMode" ? onPlanningMode : onSubtaskBreakdown).toHaveBeenCalled();
|
||||
expect(onPlanningMode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expandQuickEntry();
|
||||
expect(screen.getByTestId("quick-entry-fast-toggle").getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("clears Fast state after Subtask flow reset", async () => {
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderQuickEntryBox({ onSubtaskBreakdown });
|
||||
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.click(screen.getByTestId("quick-entry-fast-toggle"));
|
||||
fireEvent.change(textarea, { target: { value: "subtask input" } });
|
||||
fireEvent.click(screen.getByTestId("subtask-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubtaskBreakdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expandQuickEntry();
|
||||
@@ -1988,23 +2002,38 @@ describe("QuickEntryBox", () => {
|
||||
expect(screen.getByTestId("quick-entry-priority-button").textContent).toContain("Normal");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "Plan", buttonId: "plan-button" },
|
||||
{ label: "Subtask", buttonId: "subtask-button" },
|
||||
])("resets priority to normal after %s flow", async ({ buttonId }) => {
|
||||
it("keeps selected priority after Plan handoff preserves the quick-add draft", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderQuickEntryBox({ onPlanningMode, onSubtaskBreakdown });
|
||||
renderQuickEntryBox({ onPlanningMode });
|
||||
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: `${buttonId} reset` } });
|
||||
fireEvent.change(textarea, { target: { value: "plan priority" } });
|
||||
openPriorityMenu();
|
||||
fireEvent.click(screen.getByTestId("quick-entry-priority-option-urgent"));
|
||||
fireEvent.click(screen.getByTestId(buttonId));
|
||||
fireEvent.click(screen.getByTestId("plan-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(buttonId === "plan-button" ? onPlanningMode : onSubtaskBreakdown).toHaveBeenCalled();
|
||||
expect(onPlanningMode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expandQuickEntry();
|
||||
expect(screen.getByTestId("quick-entry-priority-button").textContent).toContain("Urgent");
|
||||
});
|
||||
|
||||
it("resets priority to normal after Subtask flow", async () => {
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderQuickEntryBox({ onSubtaskBreakdown });
|
||||
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "subtask reset" } });
|
||||
openPriorityMenu();
|
||||
fireEvent.click(screen.getByTestId("quick-entry-priority-option-urgent"));
|
||||
fireEvent.click(screen.getByTestId("subtask-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubtaskBreakdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expandQuickEntry();
|
||||
@@ -2325,21 +2354,21 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onPlanningMode and clears input when Plan clicked", async () => {
|
||||
it("calls onPlanningMode and preserves input draft when Plan clicked", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const { props } = renderQuickEntryBox({ onPlanningMode });
|
||||
renderQuickEntryBox({ onPlanningMode });
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Plan this task" } });
|
||||
fireEvent.change(textarea, { target: { value: " Plan this task " } });
|
||||
fireEvent.click(screen.getByTestId("plan-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPlanningMode).toHaveBeenCalledWith("Plan this task");
|
||||
});
|
||||
|
||||
// Input should be cleared
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
expect(textarea.value).toBe(" Plan this task ");
|
||||
expect(localStorage.getItem(QUICK_ENTRY_STORAGE_KEY)).toBe(" Plan this task ");
|
||||
});
|
||||
|
||||
it("calls onSubtaskBreakdown and clears input when Subtask clicked", async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { useModalManager } from "../useModalManager";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
function createTaskDetail(id: string): TaskDetail {
|
||||
return {
|
||||
@@ -47,6 +48,7 @@ function createTask(id: string): Task {
|
||||
describe("useModalManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("manages open/close state for basic modals", () => {
|
||||
@@ -101,9 +103,14 @@ describe("useModalManager", () => {
|
||||
expect(result.current.newTaskInitialDescription).toBeNull();
|
||||
});
|
||||
|
||||
it("handles planning open, resume, and close lifecycle", () => {
|
||||
it("handles planning open, resume, and close lifecycle without clearing quick-add drafts", () => {
|
||||
const projectId = "proj_1";
|
||||
const quickEntryKey = scopedKey("kb-quick-entry-text", projectId);
|
||||
const inlineCreateKey = scopedKey("kb-inline-create-text", projectId);
|
||||
localStorage.setItem(quickEntryKey, "quick draft");
|
||||
localStorage.setItem(inlineCreateKey, "inline draft");
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [{ id: "plan-1" }] }),
|
||||
useModalManager({ projectId, planningSessions: [{ id: "plan-1" }] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -120,6 +127,8 @@ describe("useModalManager", () => {
|
||||
expect(result.current.isPlanningOpen).toBe(false);
|
||||
expect(result.current.planningInitialPlan).toBeNull();
|
||||
expect(result.current.planningResumeSessionId).toBeUndefined();
|
||||
expect(localStorage.getItem(quickEntryKey)).toBe("quick draft");
|
||||
expect(localStorage.getItem(inlineCreateKey)).toBe("inline draft");
|
||||
|
||||
act(() => {
|
||||
result.current.resumePlanning();
|
||||
@@ -129,6 +138,46 @@ describe("useModalManager", () => {
|
||||
expect(result.current.planningResumeSessionId).toBe("plan-1");
|
||||
});
|
||||
|
||||
it("clears scoped quick-add drafts after single-task planning completion", () => {
|
||||
const projectId = "proj_1";
|
||||
const quickEntryKey = scopedKey("kb-quick-entry-text", projectId);
|
||||
const inlineCreateKey = scopedKey("kb-inline-create-text", projectId);
|
||||
localStorage.setItem(quickEntryKey, "quick draft");
|
||||
localStorage.setItem(inlineCreateKey, "inline draft");
|
||||
const addToast = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId, planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onPlanningTaskCreated(createTask("FN-101"), addToast);
|
||||
});
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith(expect.any(String), "success");
|
||||
expect(localStorage.getItem(quickEntryKey)).toBeNull();
|
||||
expect(localStorage.getItem(inlineCreateKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears scoped quick-add drafts after multi-task planning completion", () => {
|
||||
const projectId = "proj_1";
|
||||
const quickEntryKey = scopedKey("kb-quick-entry-text", projectId);
|
||||
const inlineCreateKey = scopedKey("kb-inline-create-text", projectId);
|
||||
localStorage.setItem(quickEntryKey, "quick draft");
|
||||
localStorage.setItem(inlineCreateKey, "inline draft");
|
||||
const addToast = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId, planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onPlanningTasksCreated([createTask("FN-201"), createTask("FN-202")], addToast);
|
||||
});
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith(expect.any(String), "success");
|
||||
expect(localStorage.getItem(quickEntryKey)).toBeNull();
|
||||
expect(localStorage.getItem(inlineCreateKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("runScript sets terminalInitialCommand and opens the terminal modal", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import type { SectionId } from "../components/SettingsModal";
|
||||
import type { ToastType } from "./useToast";
|
||||
import { removeScopedItem } from "../utils/projectStorage";
|
||||
|
||||
export type DetailTaskTab =
|
||||
| "chat"
|
||||
@@ -427,18 +428,29 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const openModelOnboarding = useCallback(() => setModelOnboardingOpen(true), []);
|
||||
const closeModelOnboarding = useCallback(() => setModelOnboardingOpen(false), []);
|
||||
|
||||
const clearQuickAddPlanningDrafts = useCallback(() => {
|
||||
/*
|
||||
FNXC:QuickAddPlanningPreserve 2026-06-22-00:00:
|
||||
Planning completion, not planning exit, is the only modal-manager transition that clears preserved quick-add drafts. Use the active project id so scoped drafts are removed from the correct workspace.
|
||||
*/
|
||||
removeScopedItem("kb-quick-entry-text", options.projectId);
|
||||
removeScopedItem("kb-inline-create-text", options.projectId);
|
||||
}, [options.projectId]);
|
||||
|
||||
const onPlanningTaskCreated = useCallback((task: Task, addToast: (message: string, type?: ToastType) => void) => {
|
||||
addToast(t("modalManager.createdFromPlanning", "Created {{id}} from planning mode", { id: task.id }), "success");
|
||||
clearQuickAddPlanningDrafts();
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
}, [t]);
|
||||
}, [clearQuickAddPlanningDrafts, t]);
|
||||
|
||||
const onPlanningTasksCreated = useCallback((tasks: Task[], addToast: (message: string, type?: ToastType) => void) => {
|
||||
const ids = tasks.map((task) => task.id).join(", ");
|
||||
addToast(t("modalManager.createdMultipleFromPlanning", "Created {{ids}} from planning mode", { ids }), "success");
|
||||
clearQuickAddPlanningDrafts();
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
}, [t]);
|
||||
}, [clearQuickAddPlanningDrafts, t]);
|
||||
|
||||
const onSubtaskTasksCreated = useCallback((tasks: Task[], addToast: (message: string, type?: ToastType) => void) => {
|
||||
const ids = tasks.map((task) => task.id).join(", ");
|
||||
|
||||
Reference in New Issue
Block a user