FN-7931: fix terminal workspace picker rendering behind floating terminal modal
Fixes the portaled terminal workspace picker menu appearing invisible behind the floating terminal modal by z-layering it above the panel's floatingZ and hiding it until it is positioned. - Compute terminalWorkspaceMenuFloatingZ one layer above the floating modal's floatingZ (min 5000) so the portaled listbox always renders above the floating terminal stack. - Position the workspace picker menu synchronously via useLayoutEffect before paint, instead of relying only on the async rAF-driven position update. - Keep the menu invisible and non-interactive (visibility: hidden, pointer-events: none) until computed trigger-relative coordinates are applied, avoiding a flash at stale/fallback CSS coordinates. - Add regression tests covering floating-mode z-index layering/pre-rAF positioning and docked/below/embedded/mobile workspace-picker positioning. - Add a patch changeset describing the fix. Files changed: .changeset/terminal-workspace-picker-floating.md | 7 ++ .../dashboard/app/components/TerminalModal.tsx | 37 +++++-- .../components/__tests__/TerminalModal.test.tsx | 120 +++++++++++++++++++++ 3 files changed, 156 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7931 Fusion-Task-Lineage: 41736804-3f99-4cb7-a0fb-f755a86ffc62 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/terminal-workspace-picker-floating.md
Normal file
7
.changeset/terminal-workspace-picker-floating.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix terminal workspace drop-down rendering behind the floating terminal modal.
|
||||
category: fix
|
||||
dev: Keeps the portaled TerminalModal workspace picker above floatingZ and hidden until positioned.
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -1272,6 +1273,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
setTerminalWorkspaceMenuPosition({ top, left, width, maxHeight: constrainedHeight });
|
||||
}, [getEffectiveViewport]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!terminalWorkspaceMenuOpen) {
|
||||
return;
|
||||
}
|
||||
updateTerminalWorkspaceMenuPosition();
|
||||
}, [terminalWorkspaceMenuOpen, terminalWorkspaces.length, updateTerminalWorkspaceMenuPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalWorkspaceMenuOpen) {
|
||||
setTerminalWorkspaceMenuPosition(null);
|
||||
@@ -2438,6 +2446,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
// FNXC:Terminal 2026-06-23-04:30: Always carry the base `terminal-modal-overlay` class so the no-dim/no-blur rule applies in EVERY mode (docked, floating, AND the mobile/default sheet that is neither) — the terminal must never dim the page behind it.
|
||||
const overlayClassName = `modal-overlay open terminal-modal-overlay${isDockedMode ? " terminal-modal-overlay--docked" : ""}${isFloatingMode ? " terminal-modal-overlay--floating" : ""}`;
|
||||
const modalClassName = `modal terminal-modal${isMobileTerminal && !embedded ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}${embedded ? " terminal-modal--embedded" : ""}`;
|
||||
/*
|
||||
FNXC:TerminalWorkspaces 2026-07-13-00:00:
|
||||
The workspace picker menu is portaled to `document.body`, so floating terminal mode must compare it in the same root stacking context as the panel. Keep the menu one layer above the panel's shared `floatingZ`; otherwise the fixed CSS fallback band sits below the 10100+ floating stack and the menu appears invisible behind the modal.
|
||||
|
||||
FNXC:TerminalWorkspaces 2026-07-13-00:00:
|
||||
The portaled listbox has CSS fallback coordinates for non-JS resilience, but it must never paint there during the open-frame measurement pass. Position in a layout effect and keep the menu invisible/non-interactive until the computed trigger-relative coordinates are applied.
|
||||
*/
|
||||
const terminalWorkspaceMenuFloatingZ = isFloatingMode ? Math.max(5000, floatingZ + 1) : undefined;
|
||||
|
||||
const modalStyle = {
|
||||
...(keyboardOverlap > 0
|
||||
? {
|
||||
@@ -2739,14 +2756,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
className="terminal-workspace-picker-menu"
|
||||
role="listbox"
|
||||
aria-label={t("terminal.selectWorkspace", "Select terminal workspace")}
|
||||
style={terminalWorkspaceMenuPosition
|
||||
? {
|
||||
top: terminalWorkspaceMenuPosition.top,
|
||||
left: terminalWorkspaceMenuPosition.left,
|
||||
width: terminalWorkspaceMenuPosition.width,
|
||||
maxHeight: terminalWorkspaceMenuPosition.maxHeight,
|
||||
}
|
||||
: undefined}
|
||||
style={{
|
||||
...(terminalWorkspaceMenuPosition
|
||||
? {
|
||||
top: terminalWorkspaceMenuPosition.top,
|
||||
left: terminalWorkspaceMenuPosition.left,
|
||||
width: terminalWorkspaceMenuPosition.width,
|
||||
maxHeight: terminalWorkspaceMenuPosition.maxHeight,
|
||||
}
|
||||
: {}),
|
||||
...(terminalWorkspaceMenuFloatingZ ? { zIndex: terminalWorkspaceMenuFloatingZ } : {}),
|
||||
...(!terminalWorkspaceMenuPosition ? { visibility: "hidden", pointerEvents: "none" } : {}),
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -642,6 +642,126 @@ describe("TerminalModal", () => {
|
||||
expect(createTab).toHaveBeenCalledWith({ cwd: "/repo/.worktrees/duplicate", title: "FN-9998" });
|
||||
});
|
||||
|
||||
function mockPopulatedTerminalWorkspaces(): void {
|
||||
mockUseWorkspaces.mockReturnValue({
|
||||
projectName: "kb",
|
||||
workspaces: [
|
||||
{ id: "FN-7253", label: "FN-7253", title: "Add worktree picker", worktree: "/repo/.worktrees/fn-7253", kind: "task" },
|
||||
{ id: "FN-0000", label: "FN-0000", title: "Missing worktree", kind: "task" },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
function mockWorkspaceTriggerRect(trigger: Element, rect: Partial<DOMRect> = {}): void {
|
||||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||||
x: 220,
|
||||
y: 18,
|
||||
top: 18,
|
||||
left: 220,
|
||||
right: 352,
|
||||
bottom: 54,
|
||||
width: 132,
|
||||
height: 36,
|
||||
toJSON: () => ({}),
|
||||
...rect,
|
||||
} as DOMRect);
|
||||
}
|
||||
|
||||
it("layers the floating workspace picker above the terminal and positions it before the rAF fallback", async () => {
|
||||
const requestAnimationFrameSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 123);
|
||||
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
|
||||
mockPopulatedTerminalWorkspaces();
|
||||
window.localStorage.setItem("fusion:terminal-display-mode-floating-layering", "floating");
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="floating-layering" />);
|
||||
|
||||
const modal = await screen.findByTestId("terminal-modal");
|
||||
expect(modal).toHaveClass("terminal-modal--floating");
|
||||
const trigger = screen.getByLabelText("Select terminal workspace: Project Root");
|
||||
mockWorkspaceTriggerRect(trigger);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const listbox = screen.getByRole("listbox", { name: "Select terminal workspace" });
|
||||
expect(listbox.parentElement).toBe(document.body);
|
||||
expect(requestAnimationFrameSpy).toHaveBeenCalled();
|
||||
expect(Number.parseFloat(listbox.style.zIndex)).toBeGreaterThan(Number.parseFloat(modal.style.zIndex));
|
||||
expect(listbox.style.top).not.toBe("");
|
||||
expect(listbox.style.left).not.toBe("");
|
||||
expect(listbox.style.width).not.toBe("");
|
||||
expect(listbox.style.maxHeight).not.toBe("");
|
||||
expect(listbox).not.toHaveStyle({ visibility: "hidden" });
|
||||
expect(listbox).not.toHaveStyle({ pointerEvents: "none" });
|
||||
expect(listbox).toHaveTextContent("Project Root");
|
||||
expect(listbox).toHaveTextContent("FN-7253");
|
||||
expect(screen.getByText("No worktree").closest("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["docked", { projectId: "workspace-picker-docked", displayMode: "docked", embedded: false, mobile: false }],
|
||||
["below", { projectId: "workspace-picker-below", displayMode: "below", embedded: false, mobile: false }],
|
||||
["embedded", { projectId: "workspace-picker-embedded", displayMode: "docked", embedded: true, mobile: false }],
|
||||
["mobile", { projectId: "workspace-picker-mobile", displayMode: "docked", embedded: false, mobile: true }],
|
||||
])("keeps the workspace picker positioned in %s terminal mode", async (_label, config) => {
|
||||
mockPopulatedTerminalWorkspaces();
|
||||
const previousInnerWidth = window.innerWidth;
|
||||
const previousInnerHeight = window.innerHeight;
|
||||
const previousOntouchstart = window.ontouchstart;
|
||||
window.localStorage.setItem(`fusion:terminal-display-mode-${config.projectId}`, config.displayMode);
|
||||
if (config.mobile) {
|
||||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||||
Object.defineProperty(window, "innerHeight", { value: 720, configurable: true });
|
||||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||||
_resetInitialViewportHeight();
|
||||
}
|
||||
|
||||
try {
|
||||
render(
|
||||
<TerminalModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
projectId={config.projectId}
|
||||
embedded={config.embedded}
|
||||
scopeId={config.embedded ? "FN-7253" : undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const modal = await screen.findByTestId("terminal-modal");
|
||||
if (config.displayMode === "below" && !config.mobile && !config.embedded) {
|
||||
expect(modal).toHaveClass("terminal-modal--below");
|
||||
} else if (config.embedded) {
|
||||
expect(screen.getByTestId("terminal-embedded-host")).toBeInTheDocument();
|
||||
} else if (config.mobile) {
|
||||
expect(modal).not.toHaveClass("terminal-modal--floating");
|
||||
expect(modal).not.toHaveClass("terminal-modal--docked");
|
||||
} else {
|
||||
expect(modal).toHaveClass("terminal-modal--docked");
|
||||
}
|
||||
|
||||
const trigger = screen.getByLabelText("Select terminal workspace: Project Root");
|
||||
mockWorkspaceTriggerRect(trigger, config.mobile ? { right: 360, width: 140 } : {});
|
||||
fireEvent.click(trigger);
|
||||
const listbox = screen.getByRole("listbox", { name: "Select terminal workspace" });
|
||||
expect(listbox.parentElement).toBe(document.body);
|
||||
expect(listbox.style.top).not.toBe("");
|
||||
expect(listbox.style.left).not.toBe("");
|
||||
expect(listbox).not.toHaveStyle({ visibility: "hidden" });
|
||||
expect(listbox).toHaveTextContent("Project Root");
|
||||
expect(listbox).toHaveTextContent("FN-7253");
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||||
Object.defineProperty(window, "innerHeight", { value: previousInnerHeight, configurable: true });
|
||||
if (previousOntouchstart === undefined) {
|
||||
delete (window as any).ontouchstart;
|
||||
} else {
|
||||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||||
}
|
||||
_resetInitialViewportHeight();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps floating and mobile worktree menus reachable and dismissible without orphaned controls", async () => {
|
||||
const createTab = vi.fn().mockResolvedValue(defaultTab);
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
|
||||
Reference in New Issue
Block a user