fix(dashboard): keep Planning Mode and Settings typable after floating-window migration

FN-8606 declared the window shell as a component inside PlanningModeModal and
SettingsModal render. A component declared in render is a new element type on
every render, so React remounted the whole subtree on each keystroke, destroying
the focused input: Planning Mode dropped everything after the first character and
Settings text fields did the same.

Replace ModalShell with a plain renderModalShell(children) call so the returned
element types stay stable, and add a Planning regression test that types
per-character across both the modal and embedded surfaces (fireEvent.change
cannot observe this class of bug, which is why it shipped).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-26 17:18:43 -07:00
parent 0022621d22
commit 7065d03895
4 changed files with 49 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Planning Mode and Settings dropping typed text after the first character.
category: fix
dev: The FN-8606 floating-window migration declared `ModalShell` as a component inside `PlanningModeModal`/`SettingsModal` render, so each render produced a new element type and remounted the whole subtree, destroying the focused input. Replaced with a plain `renderModalShell(children)` call so element types stay stable.

View File

@@ -3540,7 +3540,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
); );
/* FNXC:ModalTouchGeometry 2026-07-26-14:25: Preserve Planning Mode's pre-migration responsive desktop shell as the FloatingWindow seed so shared persistence does not shrink the surface. */ /* FNXC:ModalTouchGeometry 2026-07-26-14:25: Preserve Planning Mode's pre-migration responsive desktop shell as the FloatingWindow seed so shared persistence does not shrink the surface. */
const ModalShell = ({ children }: { children: ReactNode }) => isEmbedded ? ( /*
FNXC:ModalTouchGeometry 2026-07-26-19:40:
The shell MUST be a plain render function, never a component declared inside this render. A nested component is a
brand-new element type on every render, so React unmounts and remounts the entire Planning subtree on each keystroke:
the composer textarea is recreated, loses DOM focus, and typing appears dead after the first character. Keep the
returned element types (div / FloatingWindow) stable by calling this directly.
*/
const renderModalShell = (children: ReactNode) => isEmbedded ? (
<div className="planning-view open" data-testid="planning-view" role="region" aria-label={t("planning.title", "Planning Mode")}> <div className="planning-view open" data-testid="planning-view" role="region" aria-label={t("planning.title", "Planning Mode")}>
{children} {children}
</div> </div>
@@ -3564,8 +3571,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</FloatingWindow> </FloatingWindow>
); );
return ( return renderModalShell(
<ModalShell> (
<div className={isEmbedded ? "modal modal-lg planning-modal planning-modal--embedded" : "modal modal-lg planning-modal"} ref={modalRef}> <div className={isEmbedded ? "modal modal-lg planning-modal planning-modal--embedded" : "modal modal-lg planning-modal"} ref={modalRef}>
{/* {/*
FNXC:PlanningMode 2026-06-22-00:00: FNXC:PlanningMode 2026-06-22-00:00:
@@ -4250,7 +4257,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</div> </div>
</div> </div>
</ModalShell> )
); );
} }

View File

@@ -4619,7 +4619,13 @@ export function SettingsModal({
FNXC:Settings 2026-06-22-00:00: FNXC:Settings 2026-06-22-00:00:
Embedded settings is a main-content destination, not a dialog. It drops the fixed `.modal-overlay` backdrop and the inner card chrome (modal-overlay/modal/settings-modal classes), and instead uses `settings-embedded right-dock-embedded-view` (host) + `settings-modal--embedded` (panel) to fill the pane flush like other embedded views (Planning, Command Center). The modal path stays byte-identical. Embedded settings is a main-content destination, not a dialog. It drops the fixed `.modal-overlay` backdrop and the inner card chrome (modal-overlay/modal/settings-modal classes), and instead uses `settings-embedded right-dock-embedded-view` (host) + `settings-modal--embedded` (panel) to fill the pane flush like other embedded views (Planning, Command Center). The modal path stays byte-identical.
*/ */
const ModalShell = ({ children }: { children: ReactNode }) => isEmbedded ? ( /*
FNXC:ModalTouchGeometry 2026-07-26-19:40:
The shell MUST stay a plain render function, never a component declared inside this render. A nested component is a
new element type on every render, so React remounts the whole Settings subtree on each keystroke and text inputs lose
focus after one character. Keep the returned element types (div / FloatingWindow) stable by calling this directly.
*/
const renderModalShell = (children: ReactNode) => isEmbedded ? (
<div <div
className="settings-embedded right-dock-embedded-view" className="settings-embedded right-dock-embedded-view"
data-testid="settings-view" data-testid="settings-view"
@@ -4648,8 +4654,8 @@ export function SettingsModal({
</FloatingWindow> </FloatingWindow>
); );
return ( return renderModalShell(
<ModalShell> <>
<div <div
className={isEmbedded ? "modal modal-lg settings-modal settings-modal--embedded" : "modal modal-lg settings-modal"} className={isEmbedded ? "modal modal-lg settings-modal settings-modal--embedded" : "modal modal-lg settings-modal"}
style={isEmbedded ? undefined : keyboardStyle} style={isEmbedded ? undefined : keyboardStyle}
@@ -5391,7 +5397,7 @@ export function SettingsModal({
</div> </div>
</div> </div>
)} )}
</ModalShell> </>
); );
} }

View File

@@ -186,6 +186,27 @@ describe("PlanningModeModal autosize", () => {
expect(screen.getByText("Generating initial plan…")).toBeInTheDocument(); expect(screen.getByText("Generating initial plan…")).toBeInTheDocument();
}); });
/*
FNXC:PlanningMode 2026-07-26-19:40:
Typing into a Planning composer must keep the SAME textarea node mounted and focused. The FN-8606 floating-window
migration declared the modal/embedded shell as a component inside render, so every keystroke produced a new element
type, remounted the whole subtree, and dropped focus after one character — Planning Mode became untypable. Assert the
invariant with real per-character typing (fireEvent.change cannot see it) across both presentation surfaces.
*/
it.each(["modal", "embedded"] as const)("keeps the %s composer mounted and focused across keystrokes", async (presentation) => {
mockCreatePlanningDraft.mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} presentation={presentation} />);
const textarea = screen.getByPlaceholderText(/Build a user authentication/i) as HTMLTextAreaElement;
await user.click(textarea);
await user.type(textarea, "auth flow");
expect(screen.getByPlaceholderText(/Build a user authentication/i)).toBe(textarea);
expect(textarea.value).toBe("auth flow");
expect(document.activeElement).toBe(textarea);
});
it("grows initial planning textarea and caps at max", async () => { it("grows initial planning textarea and caps at max", async () => {
// This assertion owns the blank composer; do not let its draft debounce replace it with a // This assertion owns the blank composer; do not let its draft debounce replace it with a
// session view while verifying the textarea's height contract. // session view while verifying the textarea's height contract.