feat(FN-3237): fix mobile viewport teardown when closing planning mode
Merges FN-3237 to stabilize the mobile viewport teardown flow when closing the planning modal, ensuring the viewport state resets correctly. Adds test coverage for the mobile planning close path and includes a changeset for the patch release. Fusion-Task-Id: FN-3237
This commit is contained in:
5
.changeset/fn-3237-mobile-planning-viewport-fix.md
Normal file
5
.changeset/fn-3237-mobile-planning-viewport-fix.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix a mobile dashboard regression where closing Planning Mode after keyboard/visualViewport changes could leave board/list content shifted or clipped. Planning Mode now performs mobile viewport teardown (blur + top snap) on close so control returns cleanly to the dashboard.
|
||||||
@@ -1178,6 +1178,22 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
// Close the modal without abandoning the active server session. Sessions
|
// Close the modal without abandoning the active server session. Sessions
|
||||||
// remain in the list and can be resumed later. Only an explicit Delete
|
// remain in the list and can be resumed later. Only an explicit Delete
|
||||||
// (from the sidebar) cancels and removes a session.
|
// (from the sidebar) cancels and removes a session.
|
||||||
|
const resetMobileViewportAfterClose = useCallback(() => {
|
||||||
|
if (viewportMode !== "mobile") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (activeElement instanceof HTMLElement) {
|
||||||
|
activeElement.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
}, [viewportMode]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
// Save the in-progress draft so the next open restores it.
|
// Save the in-progress draft so the next open restores it.
|
||||||
if (initialPlan && view.type === "initial") {
|
if (initialPlan && view.type === "initial") {
|
||||||
@@ -1200,8 +1216,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
streamConnectionRef.current = null;
|
streamConnectionRef.current = null;
|
||||||
setIsReconnecting(false);
|
setIsReconnecting(false);
|
||||||
setIsRetrying(false);
|
setIsRetrying(false);
|
||||||
|
resetMobileViewportAfterClose();
|
||||||
onClose();
|
onClose();
|
||||||
}, [flushDraftAndSummarize, initialPlan, onClose, projectId, view.type]);
|
}, [flushDraftAndSummarize, initialPlan, onClose, projectId, resetMobileViewportAfterClose, view.type]);
|
||||||
|
|
||||||
// Handle escape key to close
|
// Handle escape key to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1475,12 +1492,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
currentSessionIdRef.current = null;
|
currentSessionIdRef.current = null;
|
||||||
setLockSessionId(null);
|
setLockSessionId(null);
|
||||||
setSelectedSessionId(null);
|
setSelectedSessionId(null);
|
||||||
onClose();
|
handleClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err) || "Failed to create tasks");
|
setError(getErrorMessage(err) || "Failed to create tasks");
|
||||||
setView({ type: "breakdown", sessionId: view.sessionId, subtasks: view.subtasks, dirty: view.dirty });
|
setView({ type: "breakdown", sessionId: view.sessionId, subtasks: view.subtasks, dirty: view.dirty });
|
||||||
}
|
}
|
||||||
}, [broadcastCompleted, view, onTasksCreated, onClose, projectId]);
|
}, [broadcastCompleted, handleClose, view, onTasksCreated, projectId]);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (view.type === "question" && responseHistory.length > 0) {
|
if (view.type === "question" && responseHistory.length > 0) {
|
||||||
|
|||||||
@@ -349,6 +349,42 @@ describe("PlanningModeModal", () => {
|
|||||||
expect(screen.queryByText("Planning Mode")).toBeNull();
|
expect(screen.queryByText("Planning Mode")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("mobile close path blurs focused input and resets viewport scroll", () => {
|
||||||
|
mockViewport("mobile");
|
||||||
|
const scrollToSpy = vi.spyOn(window, "scrollTo").mockImplementation(() => undefined);
|
||||||
|
const rafSpy = vi
|
||||||
|
.spyOn(window, "requestAnimationFrame")
|
||||||
|
.mockImplementation((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlanningModeModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onTaskCreated={mockOnTaskCreated}
|
||||||
|
onTasksCreated={vi.fn()}
|
||||||
|
tasks={mockTasks}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/) as HTMLTextAreaElement;
|
||||||
|
act(() => {
|
||||||
|
textarea.focus();
|
||||||
|
});
|
||||||
|
const blurSpy = vi.spyOn(textarea, "blur");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(blurSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith(0, 0);
|
||||||
|
expect(rafSpy).toHaveBeenCalled();
|
||||||
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("hides send to background button in initial state", () => {
|
it("hides send to background button in initial state", () => {
|
||||||
render(
|
render(
|
||||||
<PlanningModeModal
|
<PlanningModeModal
|
||||||
|
|||||||
Reference in New Issue
Block a user