diff --git a/packages/dashboard/app/components/InlineCreateCard.tsx b/packages/dashboard/app/components/InlineCreateCard.tsx index 7a61dc7946..cffe986978 100644 --- a/packages/dashboard/app/components/InlineCreateCard.tsx +++ b/packages/dashboard/app/components/InlineCreateCard.tsx @@ -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(); diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 76e84e44ac..319af1bb59 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -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(); diff --git a/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx b/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx index 280f9493de..762b230f1c 100644 --- a/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx @@ -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", () => { diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index 100b9fa5c5..e1c10144bd 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -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 () => { diff --git a/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts b/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts index 9beb72e72f..5eeccc48fb 100644 --- a/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts @@ -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: [] }), diff --git a/packages/dashboard/app/hooks/useModalManager.ts b/packages/dashboard/app/hooks/useModalManager.ts index 61065958bf..e46303b858 100644 --- a/packages/dashboard/app/hooks/useModalManager.ts +++ b/packages/dashboard/app/hooks/useModalManager.ts @@ -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(", ");