diff --git a/.changeset/unified-floating-window-stack.md b/.changeset/unified-floating-window-stack.md new file mode 100644 index 0000000000..046cc9c26b --- /dev/null +++ b/.changeset/unified-floating-window-stack.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Floating modals (the reusable FloatingWindow, the right-dock pop-out, the floating terminal, and the floating New Task dialog) now share a single z-index stack, so tapping any of them brings it to the front above all the others regardless of type. diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index 5d4fe35940..57dff2a1d8 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { X } from "lucide-react"; +import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; import "./FloatingWindow.css"; /* @@ -46,13 +47,9 @@ const DEFAULT_MIN_HEIGHT = 280; const VIEWPORT_PADDING = 16; /* -FNXC:FloatingWindow 2026-06-22-20:45: -Base z-index band sits at 4000+, above ordinary page content and interoperable with the existing terminal/right-dock pop-out band. `nextZ()` bumps the shared counter so a freshly mounted or freshly clicked window comes to the front. The counter is module-level and intentionally monotonic — it only ever climbs, which is fine for a session-length dashboard. +FNXC:FloatingWindow 2026-06-22-21:30: +Z-index now comes from the SHARED `floatingWindowStack` module (`nextFloatingZ`/`currentFloatingZ`) so FloatingWindow stacks in ONE counter with the right-dock pop-out, the floating terminal, and the floating New Task dialog — tapping ANY of them raises it above all the others regardless of type. The local `topZ`/`nextZ` counter this file previously owned is gone. */ -let topZ = 4000; -function nextZ(): number { - return ++topZ; -} type ResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; @@ -113,8 +110,8 @@ export function FloatingWindow({ const initialSize = clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize); return defaultPosition ? clampPosition(defaultPosition, initialSize) : defaultPositionFor(windowKey, initialSize); }); - // FNXC:FloatingWindow 2026-06-22-20:45: Each window owns its z-index; mounting claims the front of the stack. - const [zIndex, setZIndex] = useState(() => nextZ()); + // FNXC:FloatingWindow 2026-06-22-21:30: Each window owns its z-index; mounting claims the front of the SHARED cross-type stack. + const [zIndex, setZIndex] = useState(() => nextFloatingZ()); /* FNXC:FloatingWindow 2026-06-22-20:45: @@ -122,12 +119,12 @@ export function FloatingWindow({ */ const dragTeardownRef = useRef<(() => void) | null>(null); - // FNXC:FloatingWindow 2026-06-22-20:45: Focus-to-front. Pointerdown/focus anywhere on the panel raises this window above the rest. + // FNXC:FloatingWindow 2026-06-22-21:30: Focus-to-front. Pointerdown/focus anywhere on the panel raises this window above ALL other floating modals (any type) via the shared stack. const bringToFront = useCallback(() => { setZIndex((current) => { // Only claim a new z if we are not already on top, to avoid needless counter churn on every move. - if (current > topZ) return current; - return nextZ(); + if (current >= currentFloatingZ()) return current; + return nextFloatingZ(); }); }, []); diff --git a/packages/dashboard/app/components/NewTaskModal.css b/packages/dashboard/app/components/NewTaskModal.css index 5d8a0a496e..887cae1453 100644 --- a/packages/dashboard/app/components/NewTaskModal.css +++ b/packages/dashboard/app/components/NewTaskModal.css @@ -16,6 +16,14 @@ The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window (mi pointer-events: none; } +/* +FNXC:FloatingWindow 2026-06-22-21:30: +Only the desktop FLOATING New Task dialog joins the shared cross-type floating stack. When the overlay hosts the floating panel, reset the base `.modal-overlay` z-index:100 to auto so it does NOT establish a stacking context; the panel's inline z-index (from floatingWindowStack, 4000+) then interleaves at the root with the terminal, the right-dock pop-out, and FloatingWindow. The mobile full-screen sheet (no `--floating` panel) keeps the base overlay z-index:100 so it still paints above page content. +*/ +.modal-overlay.new-task-modal-overlay:has(.new-task-modal--floating) { + z-index: auto; +} + /* FNXC:NewTask 2026-06-22-20:30: Floating panel positioned by state-driven inline left/top/width/height. min/max keep content usable and the panel on-screen; `resize: none` because the corner/edge handles own resizing (the native grip conflicts with the pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. Desktop only — mobile keeps the full-screen keyboard-aware sheet. diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 0c39156399..be8664db1f 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -17,6 +17,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useNodes } from "../hooks/useNodes"; import { useViewportMode } from "../hooks/useViewportMode"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; +import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; interface NewTaskModalProps { isOpen: boolean; @@ -147,6 +148,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, const [size, setSizeState] = useState(() => readFloatSize()); const [position, setPositionState] = useState(() => readFloatPosition(readFloatSize())); const dragTeardownRef = useRef<(() => void) | null>(null); + // FNXC:FloatingWindow 2026-06-22-21:30: Floating (desktop) New Task dialog shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type. Mobile keeps the full-screen sheet so this z-index is harmless there. + const [zIndex, setZIndex] = useState(() => nextFloatingZ()); + const bringToFront = useCallback(() => { + setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); + }, []); const persistSize = useCallback((next: FloatSize) => { setSizeState(writeFloatSize(next)); @@ -737,7 +743,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, // FNXC:NewTask 2026-06-22-20:30: Desktop = floating fixed panel positioned by state-driven left/top/width/height. Mobile keeps the keyboard-aware full-screen sheet (no floating). The transparent click-through overlay never dismisses on click; the header X / Cancel / Escape are the only dismissals. const panelStyle: CSSProperties = isFloating - ? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px` } + ? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px`, zIndex } : keyboardStyle; return ( @@ -752,6 +758,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
{isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => (
(() => readExpandSize()); const [position, setPositionState] = useState(() => readExpandPosition(readExpandSize())); + // FNXC:FloatingWindow 2026-06-22-21:30: The right-dock pop-out shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type. + const [zIndex, setZIndex] = useState(() => nextFloatingZ()); + const bringToFront = useCallback(() => { + setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); + }, []); /* FNXC:RightDock 2026-06-22-17:40: @@ -294,11 +300,17 @@ export function RightDockExpandModal({ top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px`, + zIndex, } as CSSProperties; return (
-
+
{EXPAND_RESIZE_DIRECTIONS.map((direction) => (
isTerminalMobileViewport()); const isDockedMode = !isMobileTerminal && displayMode === "docked"; const isFloatingMode = !isMobileTerminal && displayMode === "floating"; + // FNXC:FloatingWindow 2026-06-22-21:30: The FLOATING terminal shares the SINGLE cross-type floating z-index stack (floatingWindowStack) so tapping it raises it above every other floating modal regardless of type. A fresh z is claimed each time the modal opens (see effect below); tapping the panel (pointerdown/focus capture) re-raises it. Docked/mobile modes ignore this z-index (full-width bottom panel / full-screen sheet). + const [floatingZ, setFloatingZ] = useState(() => nextFloatingZ()); + const bringFloatingToFront = useCallback(() => { + if (!isFloatingMode) return; + setFloatingZ((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); + }, [isFloatingMode]); const terminalRef = useRef(null); const modalRef = useRef(null); @@ -711,9 +718,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG // Bump open generation whenever the modal opens so the initialCommand // effect re-evaluates after a close/reopen cycle (deps may be identical). + // FNXC:FloatingWindow 2026-06-22-21:30: Each open also claims the front of the shared floating-window stack so a freshly-opened floating terminal sits above other floating modals. useEffect(() => { if (isOpen) { setOpenGeneration((g) => g + 1); + setFloatingZ(nextFloatingZ()); } }, [isOpen]); @@ -1758,6 +1767,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG "--terminal-float-y": `${floatingPosition.y}px`, "--terminal-float-width": `${floatingSize.width}px`, "--terminal-float-height": `${floatingSize.height}px`, + // FNXC:FloatingWindow 2026-06-22-21:30: Inline z from the shared cross-type stack; only the floating panel participates. + zIndex: floatingZ, } : {}), } as CSSProperties; @@ -1783,6 +1794,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG className={modalClassName} data-testid="terminal-modal" style={modalStyle} + onPointerDownCapture={isFloatingMode ? bringFloatingToFront : undefined} + onFocusCapture={isFloatingMode ? bringFloatingToFront : undefined} > {isDockedMode && (
{}, projectId: "project-1" } as const; + +describe("floatingWindowStack (cross-type)", () => { + it("hands out a strictly increasing, shared z to every claimant", () => { + const a = nextFloatingZ(); + const b = nextFloatingZ(); + expect(b).toBeGreaterThan(a); + expect(currentFloatingZ()).toBe(b); + }); + + it("tapping a FloatingWindow raises it above a right-dock pop-out opened after it (and vice versa)", () => { + render( + <> + {}}> +
fw body
+
+ {}} /> + , + ); + + const fwPanel = screen.getByTestId("floating-window-fw"); + const dockPanel = screen + .getByTestId("right-dock-expand-modal") + .querySelector(".right-dock-expand-modal--floating") as HTMLElement; + + // Both carry an inline z-index from the shared stack. + expect(fwPanel.style.zIndex).not.toBe(""); + expect(dockPanel.style.zIndex).not.toBe(""); + + // The dock pop-out mounted last → it starts on top of the FloatingWindow, proving one shared stack. + expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(fwPanel.style.zIndex)); + + // Tapping the older FloatingWindow raises it above the dock pop-out — across the type boundary. + fireEvent.pointerDown(fwPanel); + expect(Number(fwPanel.style.zIndex)).toBeGreaterThan(Number(dockPanel.style.zIndex)); + + // Tapping the dock pop-out raises it back above the FloatingWindow. + fireEvent.pointerDown(dockPanel); + expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(fwPanel.style.zIndex)); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index bbca4701d6..15443690bd 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -670,7 +670,7 @@ describe("TaskChatTab", () => { expect(toolGroup).toHaveAttribute("open"); const invocation = screen.getByTestId("task-chat-tool-invocation"); - const kicker = screen.getByText("Tool call → result"); + const kicker = screen.getByText("Tool call → Result"); expect(invocation).toHaveClass("task-chat-tool-entry", "task-chat-tool-invocation"); expect(kicker).toHaveClass("task-chat-entry-kicker"); expect(kicker).toBeVisible(); @@ -727,7 +727,7 @@ describe("TaskChatTab", () => { await user.click(within(summary as HTMLElement).getByText("1 tool call")); - expect(screen.getByText("Tool call → error")).toBeVisible(); + expect(screen.getByText("Tool call → Error")).toBeVisible(); expect(screen.getByText("Error")).toBeVisible(); expect(screen.getByText("stderr")).toBeVisible(); }); diff --git a/packages/dashboard/app/components/floatingWindowStack.ts b/packages/dashboard/app/components/floatingWindowStack.ts new file mode 100644 index 0000000000..9fa9aa02d9 --- /dev/null +++ b/packages/dashboard/app/components/floatingWindowStack.ts @@ -0,0 +1,17 @@ +/* +FNXC:FloatingWindow 2026-06-22-21:30: +SHARED floating-window z-index stack. This is the ONE source of z-index for every floating modal in the dashboard (FloatingWindow, 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. Previously each modal type managed z-index independently, so tapping e.g. the terminal could not raise it above a popped-out task-detail FloatingWindow. Now every floating modal claims `nextFloatingZ()` on mount/open and again on every panel pointerdown/focus, so the most-recently-interacted window is always on top REGARDLESS of type. + +Base band sits at 4000+ — above ordinary page content and above the base `.modal-overlay` (z-index 100). 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. +*/ +let topZ = 4000; + +/** Claim the front of the shared floating-window stack. Monotonic, session-length. */ +export function nextFloatingZ(): number { + return ++topZ; +} + +/** Current top of the stack (read-only). Lets a window skip a needless bump when already on top. */ +export function currentFloatingZ(): number { + return topZ; +} diff --git a/packages/dashboard/vitest.setup.ts b/packages/dashboard/vitest.setup.ts index 1b651cb65e..c2b1471981 100644 --- a/packages/dashboard/vitest.setup.ts +++ b/packages/dashboard/vitest.setup.ts @@ -16,7 +16,29 @@ await i18next.use(initReactI18next).init({ // Each namespace present (empty) so hasLoadedNamespace() is true — an // unloaded namespace makes useTranslation() suspend (no Suspense boundary // in component tests) even with useSuspense disabled belt-and-braces below. - resources: { en: { common: {}, app: {}, errors: {} } }, + // + // FNXC:TestI18n 2026-06-22-21:40: + // Pluralized count keys must resolve from resources, not the singular inline + // default. t("taskChat.entryCount", "{{count}} entry", { count }) renders the + // singular default for ALL counts when the key is absent — so count=2 became + // "2 entry". Provide the _one/_other forms (as the real en locale does) so the + // correct plural ("2 entries", "7 tool calls") renders in tests too. Only these + // keys resolve from the bundle; every other key still falls back to its inline + // default, preserving existing assertions. + resources: { + en: { + common: {}, + app: { + taskChat: { + entryCount_one: "{{count}} entry", + entryCount_other: "{{count}} entries", + toolCallCount_one: "{{count}} tool call", + toolCallCount_other: "{{count}} tool calls", + }, + }, + errors: {}, + }, + }, ns: ["common", "app", "errors"], defaultNS: "common", interpolation: { escapeValue: false },