FN-7914: fix mobile prompt fullscreen editor Expand button hidden behind workflow sheet

Fixes the workflow editor's fullscreen prompt overlay having an inert-looking mobile Expand button because a static z-index of 10000 sat below the mobile workflow editor's full-screen FloatingWindow sheet (10100+ shared floating-stack band).

- Have the fullscreen prompt overlay claim a fresh shared floating-stack z-index via nextFloatingZ() when opened, instead of relying on the static CSS z-index: 10000 fallback
- Track the claimed z-index in new promptFullscreenZ state, applying it as an inline style on the portaled overlay and clearing it on collapse/Escape
- Update FNXC comments in WorkflowNodeEditor.css and floatingWindowStack.ts documenting the shared z-index contract and why the static value is now only a fallback
- Add regression tests asserting the prompt fullscreen overlay's z-index exceeds the workflow editor's floating window z-index on desktop, mobile prompt nodes, mobile gate nodes, and after re-opening after collapse

Files changed:
 .../app/components/WorkflowNodeEditor.css          |  1 +
 .../app/components/WorkflowNodeEditor.tsx          | 25 ++++++-
 .../__tests__/WorkflowNodeEditor.test.tsx          | 81 ++++++++++++++++++++++
 .../app/components/floatingWindowStack.ts          |  2 +-
 4 files changed, 106 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7914

Fusion-Task-Lineage: e230309a-3b53-4e42-9e63-7516597b6c24

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 22:15:48 -07:00
parent daf3f15fd1
commit 504b0f8b04
4 changed files with 106 additions and 3 deletions

View File

@@ -1179,6 +1179,7 @@ Keep logical overflow and tab intrinsic-size declarations beside the physical fa
left: 0;
right: 0;
bottom: 0;
/* FNXC:WorkflowEditor 2026-07-12-00:00: Keep this as a static fallback only; WorkflowNodeEditor applies an inline shared floating-stack z-index when the prompt overlay opens so mobile FloatingWindow sheets cannot cover it. */
z-index: 10000;
background: var(--surface);
padding: max(var(--space-lg), env(safe-area-inset-top, 0))

View File

@@ -108,6 +108,7 @@ import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { FloatingWindow } from "./FloatingWindow";
import { nextFloatingZ } from "./floatingWindowStack";
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
import {
buildMobileWorkflowGraph,
@@ -2412,14 +2413,29 @@ function InnerEditor({
const mobileNodeDetailStage = isMobileMode && selectedNodeHasInspector && !inspectorCollapsed;
const mobileEdgeDetailStage = isMobileMode && selectedEdge !== null;
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
const [promptFullscreenZ, setPromptFullscreenZ] = useState<number | null>(null);
const handleTogglePromptExpand = useCallback(() => {
setIsPromptExpanded((prev) => !prev);
}, []);
if (isPromptExpanded) {
setIsPromptExpanded(false);
setPromptFullscreenZ(null);
return;
}
setPromptFullscreenZ(nextFloatingZ());
setIsPromptExpanded(true);
}, [isPromptExpanded]);
useEffect(() => {
if (!isPromptExpanded) {
if (promptFullscreenZ !== null) setPromptFullscreenZ(null);
return;
}
if (promptFullscreenZ === null) setPromptFullscreenZ(nextFloatingZ());
}, [isPromptExpanded, promptFullscreenZ]);
const handlePromptFullscreenKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (!isPromptExpanded || e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
setIsPromptExpanded(false);
setPromptFullscreenZ(null);
}, [isPromptExpanded]);
const selectedNodePromptValue =
selectedNode && (selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate")
@@ -2725,11 +2741,16 @@ function InnerEditor({
};
}, [overrideColumnBinding, agents.length, projectId, addToast, t]);
/*
FNXC:WorkflowEditor 2026-07-12-00:00:
The fullscreen prompt editor is portaled beside the FloatingWindow that launched it, so it must claim a fresh shared floating-stack z-index when opened. A static z-index of 10000 is below the workflow editor's 10100+ full-screen mobile FloatingWindow sheet and makes the mobile Expand button appear inert because the sheet covers the overlay.
*/
const promptFullscreenOverlay =
isPromptExpanded && (selectedNode?.data.kind === "prompt" || selectedNode?.data.kind === "gate")
? createPortal(
<div
className="wf-prompt-editor wf-prompt-editor--fullscreen"
style={promptFullscreenZ !== null ? { zIndex: promptFullscreenZ } : undefined}
onKeyDown={handlePromptFullscreenKeyDown}
>
<div className="wf-prompt-fullscreen-header">

View File

@@ -113,6 +113,23 @@ function getPromptFullscreenTextarea() {
return within(overlay!).getByLabelText("Prompt") as HTMLTextAreaElement;
}
function getWorkflowEditorFloatingOverlay() {
return document.body.querySelector('[data-testid="floating-window-overlay-workflow-node-editor"]') as HTMLElement | null;
}
function zIndexOf(element: HTMLElement) {
const value = element.style.zIndex || window.getComputedStyle(element).zIndex;
return Number.parseInt(value, 10);
}
function expectPromptOverlayAboveWorkflowWindow() {
const workflowWindow = getWorkflowEditorFloatingOverlay();
const promptOverlay = getPromptFullscreenOverlay();
expect(workflowWindow).toBeInTheDocument();
expect(promptOverlay).toBeInTheDocument();
expect(zIndexOf(promptOverlay!)).toBeGreaterThan(zIndexOf(workflowWindow!));
}
function defineElementMetric(element: Element, property: "clientWidth" | "scrollWidth", value: number) {
Object.defineProperty(element, property, { configurable: true, value });
}
@@ -1395,6 +1412,53 @@ describe("WorkflowNodeEditor", () => {
expect(screen.getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
});
it("stacks the fullscreen prompt editor above the workflow floating window on desktop", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
const workflowWindow = getWorkflowEditorFloatingOverlay();
expect(workflowWindow).toBeInTheDocument();
expect(zIndexOf(workflowWindow!)).toBeGreaterThan(10000);
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
expectPromptOverlayAboveWorkflowWindow();
});
it("stacks the fullscreen prompt editor above the mobile workflow sheet for prompt nodes", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
const inspector = await screen.findByTestId("wf-node-inspector");
fireEvent.click(within(inspector).getByRole("button", { name: "Expand prompt editor" }));
expectPromptOverlayAboveWorkflowWindow();
});
it("stacks the fullscreen prompt editor above the mobile workflow sheet for empty gate prompts", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
fireEvent.click(await screen.findByTestId("wf-node-gate"));
const inspector = await screen.findByTestId("wf-node-inspector");
fireEvent.click(within(inspector).getByRole("button", { name: "Expand prompt editor" }));
expect(within(getPromptFullscreenOverlay()!).getByLabelText("Prompt")).toHaveValue("");
expectPromptOverlayAboveWorkflowWindow();
});
it("collapses the fullscreen prompt editor on Escape", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
@@ -1571,6 +1635,7 @@ describe("WorkflowNodeEditor", () => {
const fullscreenPromptEditor = getPromptFullscreenOverlay();
expect(fullscreenPromptEditor).toBeInTheDocument();
expect(fullscreenPromptEditor).toHaveClass("wf-prompt-editor--fullscreen");
expectPromptOverlayAboveWorkflowWindow();
fireEvent.click(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" }));
@@ -1664,6 +1729,22 @@ describe("WorkflowNodeEditor — embedded presentation", () => {
expect(document.body.querySelector(".floating-window__resize-handle")).toBeNull();
});
it("opens the prompt fullscreen editor from embedded workflows without floating chrome", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} presentation="embedded" />);
await screen.findByText("Save");
expect(getWorkflowEditorFloatingOverlay()).toBeNull();
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
const fullscreenPromptEditor = getPromptFullscreenOverlay();
expect(fullscreenPromptEditor).toBeInTheDocument();
expect(zIndexOf(fullscreenPromptEditor!)).toBeGreaterThan(10000);
});
it("does not dismiss on Escape in embedded mode", async () => {
const onClose = vi.fn();
const { container } = render(

View File

@@ -3,7 +3,7 @@ FNXC:FloatingWindow 2026-06-22-21:30:
SHARED floating-utility z-index stack. This is the ONE source of z-index for utility floating modals in the dashboard (FloatingWindow utility callers, the right-dock pop-out, the floating terminal, the floating New Task dialog) so they interoperate in a SINGLE stack instead of each type owning a private counter. Utility windows claim `nextFloatingZ()` on mount/open and again on every panel pointerdown/focus, so the most-recently-interacted utility window is always on top REGARDLESS of type.
FNXC:FloatingWindow 2026-06-22-22:30:
Base band sits at 10100+ — ABOVE the page overlay/popover band (log viewer, workflow-editor modal, selection popover, fullscreen overlay at z 10000-10001) so a utility floating window the user is dragging is never painted over by those. Transient top-right toasts are bumped to 10500 (styles.css) so system feedback still shows above a dragged utility window. The counter is module-level and intentionally monotonic: it only ever climbs, which is fine for a session-length dashboard. All floating overlays are `pointer-events: none` (click-through) so raising panels into this shared band never traps clicks on the page behind them. CRITICAL: every floating modal must be portaled to document.body so this shared z is compared in ONE root stacking context (an inline panel cannot beat siblings outside its own context no matter its z).
Base band sits at 10100+ — ABOVE the page overlay/popover band (log viewer, workflow-editor modal, selection popover, static fullscreen fallbacks at z 10000-10001) so a utility floating window the user is dragging is never painted over by those. Transient top-right toasts are bumped to 10500 (styles.css) so system feedback still shows above a dragged utility window. The workflow prompt fullscreen overlay is itself a floating utility surface and claims `nextFloatingZ()` when opened, because a static z 10000 fallback is hidden by the workflow editor's full-screen mobile FloatingWindow sheet. The counter is module-level and intentionally monotonic: it only ever climbs, which is fine for a session-length dashboard. All floating overlays are `pointer-events: none` (click-through) so raising panels into this shared band never traps clicks on the page behind them. CRITICAL: every floating modal must be portaled to document.body so this shared z is compared in ONE root stacking context (an inline panel cannot beat siblings outside its own context no matter its z).
FNXC:TaskPopupLayer 2026-07-04-18:36:
Task-detail popups are ordinary board/task-detail surfaces, not global utilities. Keep their focus stack in a lower board-layer band so task popups can raise among themselves without covering terminal, right-dock expand, Quick Chat, file browser, workflow editor, or other utility windows that intentionally use the 10100+ band.