FN-7959: focus Planning compose textarea on New session
Focus the Planning compose textarea when New session is pressed, even if blank compose is already active, and preserve in-progress draft text. - Add a click-driven newSessionFocusSignal so New session always re-focuses the compose textarea after rAF (mobile detail pane visibility) - Preserve initialPlan when starting a new session from the already-active blank compose view - Cover focus, caret placement, draft preservation, and mobile show-detail surfaces in PlanningModeModal tests - Add patch changeset for @runfusion/fusion Files changed: .changeset/fn-7959-planning-new-session-focus.md | 6 + .../dashboard/app/components/PlanningModeModal.tsx | 37 ++++- .../__tests__/PlanningModeModal.initial.test.tsx | 174 +++++++++++++++++++++ 3 files changed, 213 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7959 Fusion-Task-Lineage: ad71ad28-3e22-454a-907f-3423475180f9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
6
.changeset/fn-7959-planning-new-session-focus.md
Normal file
6
.changeset/fn-7959-planning-new-session-focus.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Pressing "New session" in Planning now always focuses the compose input.
|
||||
category: fix
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
const currentSessionIdRef = useRef<string | null>(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);
|
||||
|
||||
@@ -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(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user