diff --git a/.changeset/fn-7959-planning-new-session-focus.md b/.changeset/fn-7959-planning-new-session-focus.md new file mode 100644 index 0000000000..6e5053f9f9 --- /dev/null +++ b/.changeset/fn-7959-planning-new-session-focus.md @@ -0,0 +1,6 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Pressing "New session" in Planning now always focuses the compose input. +category: fix diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 00e0dcb9b7..9859ecaa64 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -349,6 +349,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat textareaRef.current = node; initialPlanAutosizeRef(node); }, [initialPlanAutosizeRef]); + /* + FNXC:Planning 2026-07-14-00:00: + FN-7959 requires New session to focus the compose textarea even when the blank compose view is already active, so this is a click-driven signal instead of a view/selectedSessionId effect whose dependencies can no-op. The focus runs after requestAnimationFrame because mobile swaps the detail pane from display:none to visible after mobileShowDetail commits. + */ + const [newSessionFocusSignal, setNewSessionFocusSignal] = useState(0); const modalRef = useRef(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); @@ -383,6 +388,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat viewRef.current = view; }, [view]); + useEffect(() => { + if (newSessionFocusSignal === 0 || !isOpen) { + return; + } + + const frame = window.requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + textarea.focus(); + const valueEnd = textarea.value.length; + textarea.setSelectionRange(valueEnd, valueEnd); + }); + + return () => { + window.cancelAnimationFrame(frame); + }; + }, [isOpen, newSessionFocusSignal]); + const resetPlanningAutoRetryBudget = useCallback(() => { planningAutoRetryAttemptRef.current = 0; planningAutoRetryInFlightRef.current = false; @@ -669,8 +694,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }; }, [projectId, resetPlanningAutoRetryBudget, t, view.type]); - const resetDetailState = useCallback(() => { - setInitialPlan(""); + const resetDetailState = useCallback((options?: { preserveInitialPlan?: boolean }) => { + if (!options?.preserveInitialPlan) { + setInitialPlan(""); + } setView({ type: "initial" }); setError(null); setResponseHistory([]); @@ -1421,10 +1448,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat if (resumeSessionId) { dismissedResumeRef.current = resumeSessionId; } - resetDetailState(); + const preserveActiveDraft = selectedSessionId === null && viewRef.current.type === "initial"; + resetDetailState({ preserveInitialPlan: preserveActiveDraft }); setSelectedSessionId(null); setMobileShowDetail(true); - }, [resetDetailState, resumeSessionId]); + setNewSessionFocusSignal((signal) => signal + 1); + }, [resetDetailState, resumeSessionId, selectedSessionId]); const handleBackToList = useCallback(() => { setMobileShowDetail(false); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx index 2d76bb06cc..c7887de493 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx @@ -295,6 +295,180 @@ describe("PlanningModeModal", () => { } }); + it("focuses the initial textarea when New session is clicked while already composing", () => { + const rafSpy = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + + try { + render( + , + ); + + const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement; + expect(document.activeElement).not.toBe(textarea); + + fireEvent.click(screen.getByRole("button", { name: "New session" })); + + expect(rafSpy).toHaveBeenCalled(); + expect(document.activeElement).toBe(textarea); + } finally { + rafSpy.mockRestore(); + } + }); + + it("resets a selected desktop session to compose view and focuses New session", async () => { + const rafSpy = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + mockFetchAiSessions.mockResolvedValue([ + { + id: "session-existing", + type: "planning", + status: "complete", + title: "Existing session", + preview: "An existing planning session", + projectId: null, + lockedByTab: null, + updatedAt: new Date().toISOString(), + archived: false, + }, + ]); + mockFetchAiSession.mockResolvedValue({ + id: "session-existing", + type: "planning", + status: "complete", + title: "Existing session", + inputPayload: JSON.stringify({ initialPlan: "Existing selected plan" }), + conversationHistory: "[]", + currentQuestion: null, + result: JSON.stringify(mockSummary), + error: null, + }); + + try { + render( + , + ); + + fireEvent.click(await screen.findByText("Existing session")); + + await waitFor(() => { + expect(mockFetchAiSession).toHaveBeenCalledWith("session-existing"); + }); + + fireEvent.click(screen.getByRole("button", { name: "New session" })); + + const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement; + expect(textarea.value).toBe(""); + expect(document.activeElement).toBe(textarea); + expect(screen.getByRole("button", { name: /Start Planning/ })).toBeDisabled(); + } finally { + rafSpy.mockRestore(); + } + }); + + it("shows the mobile detail pane and focuses compose when New session is clicked from the list", async () => { + mockViewport("mobile"); + const rafSpy = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + mockFetchAiSessions.mockResolvedValue([ + { + id: "session-mobile", + type: "planning", + status: "complete", + title: "Mobile session", + preview: "A mobile planning session", + projectId: null, + lockedByTab: null, + updatedAt: new Date().toISOString(), + archived: false, + }, + ]); + + try { + const { container } = render( + , + ); + + await screen.findByText("Mobile session"); + const body = container.querySelector(".planning-modal-body"); + await waitFor(() => { + expect(body?.classList.contains("planning-modal-body--show-list")).toBe(true); + }); + + fireEvent.click(screen.getByRole("button", { name: "New session" })); + + const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement; + expect(body?.classList.contains("planning-modal-body--show-detail")).toBe(true); + expect(document.activeElement).toBe(textarea); + } finally { + rafSpy.mockRestore(); + } + }); + + it("preserves existing compose draft text and moves the caret to the end on New session focus", () => { + const rafSpy = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + + try { + render( + , + ); + + const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "Keep this restored draft" } }); + textarea.setSelectionRange(0, 0); + + fireEvent.click(screen.getByRole("button", { name: "New session" })); + + expect(textarea.value).toBe("Keep this restored draft"); + expect(document.activeElement).toBe(textarea); + expect(textarea.selectionStart).toBe(textarea.value.length); + expect(textarea.selectionEnd).toBe(textarea.value.length); + } finally { + rafSpy.mockRestore(); + } + }); + it("mobile close path blurs focused input and resets viewport scroll", () => { mockViewport("mobile"); const scrollToSpy = vi.spyOn(window, "scrollTo").mockImplementation(() => undefined);