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:
gsxdsm
2026-06-22 18:03:53 -07:00
parent c229a15e20
commit 003c5d01c0
6 changed files with 133 additions and 55 deletions

View File

@@ -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();

View File

@@ -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();

View File

@@ -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", () => {

View File

@@ -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 () => {

View File

@@ -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: [] }),

View File

@@ -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(", ");