diff --git a/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx b/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx
index e1d4879966..569663e911 100644
--- a/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AddNodeModal } from "../AddNodeModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
+import { expectStableTyping } from "./typingStability.test-helpers";
describe("AddNodeModal", () => {
const defaultProps = {
@@ -34,6 +35,19 @@ describe("AddNodeModal", () => {
vi.clearAllMocks();
});
+
+ /*
+ FNXC:TypingStability 2026-07-26-22:15:
+ Per-character typing guard. The FN-8606 floating-window migration made Planning Mode and Settings
+ untypable and no test noticed, because field coverage here uses fireEvent.change, which never needs
+ the input to stay mounted. This asserts the field keeps its DOM node, value, and focus while typed.
+ */
+ it("keeps the node name field mounted and focused while typing", async () => {
+ render();
+ const field = screen.getByPlaceholderText("Build Machine") as HTMLInputElement;
+ await expectStableTyping(field, "build-01", () => screen.getByPlaceholderText("Build Machine"));
+ });
+
it("renders when isOpen is true", () => {
render();
diff --git a/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx b/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx
index 51159f8ec7..bfff143b11 100644
--- a/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ConnectNodeModal } from "../ConnectNodeModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
+import { expectStableTyping } from "./typingStability.test-helpers";
import type { NodeInfo } from "../../api";
const mockFetch = vi.fn();
@@ -40,6 +41,19 @@ describe("ConnectNodeModal", () => {
vi.clearAllMocks();
});
+
+ /*
+ FNXC:TypingStability 2026-07-26-22:15:
+ Per-character typing guard. The FN-8606 floating-window migration made Planning Mode and Settings
+ untypable and no test noticed, because field coverage here uses fireEvent.change, which never needs
+ the input to stay mounted. This asserts the field keeps its DOM node, value, and focus while typed.
+ */
+ it("keeps the node name field mounted and focused while typing", async () => {
+ render();
+ const field = screen.getByPlaceholderText("Build Server") as HTMLInputElement;
+ await expectStableTyping(field, "build-server", () => screen.getByPlaceholderText("Build Server"));
+ });
+
it("renders when open", () => {
render();
diff --git a/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx
index 058b9cc8b4..c0d89ecad6 100644
--- a/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { NodeDetailModal } from "../NodeDetailModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
+import { expectStableTyping } from "./typingStability.test-helpers";
import type { DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
vi.mock("lucide-react", () => ({
@@ -75,6 +76,20 @@ const dockerConfig: DockerNodeConfig = {
};
describe("NodeDetailModal docker section", () => {
+
+ /*
+ FNXC:TypingStability 2026-07-26-22:15:
+ Per-character typing guard. The FN-8606 floating-window migration made Planning Mode and Settings
+ untypable and no test noticed, because field coverage here uses fireEvent.change, which never needs
+ the input to stay mounted. This asserts the field keeps its DOM node, value, and focus while typed.
+ */
+ it("keeps the API key field mounted and focused while typing", async () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /Edit/ }));
+ const field = screen.getByPlaceholderText("Leave blank to keep unchanged") as HTMLInputElement;
+ await expectStableTyping(field, "tok-123", () => screen.getByPlaceholderText("Leave blank to keep unchanged"));
+ });
+
it("does not render docker section without managedDockerNode", () => {
render();
expect(screen.queryByText("Docker Management")).not.toBeInTheDocument();
diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx
index 05a407246b..e72b3b298a 100644
--- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx
+++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act, createEvent } from "@testing-library/react";
import { QuickEntryBox } from "../QuickEntryBox";
+import { expectStableTyping } from "./typingStability.test-helpers";
import { TASK_PRIORITIES, type Task, type TaskPriority } from "@fusion/core";
import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../../api";
import { useNodes } from "../../hooks/useNodes";
@@ -570,6 +571,19 @@ describe("QuickEntryBox", () => {
expectQuickEntryTestGlobalsRestored();
});
+
+ /*
+ FNXC:TypingStability 2026-07-26-22:15:
+ Per-character typing guard for the board's primary composer. FN-8606 shipped an untypable Planning
+ Mode and Settings because field coverage uses fireEvent.change, which sets a value in one shot and
+ never needs the node to stay mounted. This asserts node identity, accumulated value, and focus.
+ */
+ it("keeps the composer mounted and focused while typing", async () => {
+ renderQuickEntryBox({});
+ const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
+ await expectStableTyping(textarea, "ship it", () => screen.getByTestId("quick-entry-input"));
+ });
+
it("renders textarea with placeholder", () => {
renderQuickEntryBox({});
const textarea = screen.getByTestId("quick-entry-input");
diff --git a/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx b/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx
index 87ff29c149..b6cfe43df4 100644
--- a/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ScriptsModal } from "../ScriptsModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
+import { expectStableTyping } from "./typingStability.test-helpers";
import type { ScriptEntry } from "../../api";
const mockScripts: Record = {
@@ -41,6 +42,22 @@ describe("ScriptsModal", () => {
expect(screen.getByTestId("floating-window-scripts")).toBeInTheDocument();
});
+
+ /*
+ FNXC:TypingStability 2026-07-26-22:15:
+ Per-character typing guard. The FN-8606 floating-window migration made Planning Mode and Settings
+ untypable and no test noticed, because field coverage here uses fireEvent.change, which never needs
+ the input to stay mounted. This asserts the field keeps its DOM node, value, and focus while typed.
+ */
+ it("keeps the script name field mounted and focused while typing", async () => {
+ vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
+ render();
+
+ fireEvent.click(await screen.findByTestId("add-script-btn"));
+ const field = await screen.findByTestId("script-name-input") as HTMLInputElement;
+ await expectStableTyping(field, "lint", () => screen.getByTestId("script-name-input"));
+ });
+
it("does not render when closed", () => {
const { container } = render(
diff --git a/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx b/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx
index cb7e68f0a2..fc05d565e3 100644
--- a/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SubtaskBreakdownModal } from "../SubtaskBreakdownModal";
+import { expectStableTyping } from "./typingStability.test-helpers";
const mockStartSubtaskBreakdown = vi.fn();
const mockRetrySubtaskSession = vi.fn();
@@ -50,6 +51,7 @@ vi.mock("../../hooks/useViewportMode", () => ({
isShortViewport: () => false,
getViewportMode: () => mockViewportMode.value,
isMobileViewport: () => mockViewportMode.value === "mobile",
+ isTabletTouchViewport: (mode?: string) => mode === "tablet",
useViewportMode: () => mockViewportMode.value,
}));
@@ -304,6 +306,22 @@ describe("SubtaskBreakdownModal", () => {
expect(screen.getByDisplayValue("Do second")).toBeInTheDocument();
});
+
+ /*
+ FNXC:TypingStability 2026-07-26-22:15:
+ Per-character typing guard for subtask editing. Each keystroke updates the shared subtask list, so a
+ remount here would drop focus after one character. fireEvent.change (used elsewhere in this file)
+ cannot observe that; assert node identity, accumulated value, and focus instead.
+ */
+ it("keeps a subtask title field mounted and focused while typing", async () => {
+ renderModal();
+ await waitFor(() => expect(streamHandlers).toBeDefined());
+ streamHandlers.onSubtasks(SAMPLE_SUBTASKS);
+
+ const field = await screen.findByDisplayValue("First") as HTMLInputElement;
+ await expectStableTyping(field, " pass", () => screen.getByDisplayValue("First pass"));
+ });
+
it("shows reconnecting only during generation, not on persisted subtask review", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
diff --git a/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx
index a0704954d4..a551710ed8 100644
--- a/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx
@@ -4,6 +4,7 @@ import { MessageSquare, Repeat } from "lucide-react";
import type { WorkflowDefinition, WorkflowStepTemplate } from "@fusion/core";
import { WorkflowAddStepModal, type AddStepPaletteEntry } from "../WorkflowAddStepModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
+import { expectStableTyping } from "./typingStability.test-helpers";
/*
FNXC:WorkflowSimpleView 2026-07-12-14:30:
@@ -53,6 +54,19 @@ function renderModal(disallowContainers: boolean) {
describe("WorkflowAddStepModal", () => {
afterEach(() => cleanup());
+
+ /*
+ FNXC:TypingStability 2026-07-26-22:15:
+ Per-character typing guard. The FN-8606 floating-window migration made Planning Mode and Settings
+ untypable and no test noticed, because field coverage here uses fireEvent.change, which never needs
+ the input to stay mounted. This asserts the field keeps its DOM node, value, and focus while typed.
+ */
+ it("keeps the step search field mounted and focused while typing", async () => {
+ renderModal(false);
+ const field = screen.getByPlaceholderText("Search steps and templates…") as HTMLInputElement;
+ await expectStableTyping(field, "sec", () => screen.getByPlaceholderText("Search steps and templates…"));
+ });
+
it("offers containers, fragments, and optional-group inserts for top-level targets", () => {
renderModal(false);
expect(screen.getByTestId("wf-add-step-loop-loop")).toBeInTheDocument();
diff --git a/packages/dashboard/app/components/__tests__/typingStability.test-helpers.ts b/packages/dashboard/app/components/__tests__/typingStability.test-helpers.ts
new file mode 100644
index 0000000000..f431923917
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/typingStability.test-helpers.ts
@@ -0,0 +1,33 @@
+import { expect } from "vitest";
+import { userEvent } from "@testing-library/user-event";
+
+/*
+FNXC:TypingStability 2026-07-26-22:15:
+Shared assertion for "this field survives being typed into". A text field breaks when the render
+that follows a keystroke gives it a NEW DOM node: React drops the old element, the browser drops
+focus with it, and every character after the first lands nowhere. That is how FN-8606 shipped an
+untypable Planning Mode and Settings.
+
+`fireEvent.change` cannot observe this — it sets `value` in one shot on a node it already holds and
+never needs focus — so most of our field coverage is blind to it. This helper types character by
+character through userEvent and asserts the three things a remount destroys: node identity, the
+accumulated value, and focus.
+
+`requery` must re-run the original query (getByPlaceholderText/getByLabelText/...) so identity is
+compared against what the CURRENT tree renders, not the stale reference.
+*/
+export async function expectStableTyping(
+ field: HTMLInputElement | HTMLTextAreaElement,
+ text: string,
+ requery: () => HTMLElement,
+): Promise {
+ const user = userEvent.setup();
+ const initialValue = field.value;
+
+ await user.click(field);
+ await user.type(field, text);
+
+ expect(requery()).toBe(field);
+ expect(field.value).toBe(`${initialValue}${text}`);
+ expect(document.activeElement).toBe(field);
+}