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:
gsxdsm
2026-07-15 10:28:38 -07:00
parent 3676586460
commit 16b0109300
3 changed files with 213 additions and 4 deletions

View File

@@ -0,0 +1,6 @@
---
"@runfusion/fusion": patch
---
summary: Pressing "New session" in Planning now always focuses the compose input.
category: fix

View File

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

View File

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