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:
7
.changeset/fix-planning-settings-composer-remount.md
Normal file
7
.changeset/fix-planning-settings-composer-remount.md
Normal 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.
|
||||||
@@ -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>
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user