diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 1e303aefc5..4230aec92a 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -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)) diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 21794bf31f..5cbb0bea51 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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(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) => { 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(
diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index 1b5a2f48c3..ffa56e8d1b 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -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( {}} 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( {}} 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( {}} 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( {}} 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( diff --git a/packages/dashboard/app/components/floatingWindowStack.ts b/packages/dashboard/app/components/floatingWindowStack.ts index 47b4ded2eb..01e78631e6 100644 --- a/packages/dashboard/app/components/floatingWindowStack.ts +++ b/packages/dashboard/app/components/floatingWindowStack.ts @@ -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.