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 {
|
import {
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -1272,6 +1273,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
setTerminalWorkspaceMenuPosition({ top, left, width, maxHeight: constrainedHeight });
|
setTerminalWorkspaceMenuPosition({ top, left, width, maxHeight: constrainedHeight });
|
||||||
}, [getEffectiveViewport]);
|
}, [getEffectiveViewport]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!terminalWorkspaceMenuOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateTerminalWorkspaceMenuPosition();
|
||||||
|
}, [terminalWorkspaceMenuOpen, terminalWorkspaces.length, updateTerminalWorkspaceMenuPosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalWorkspaceMenuOpen) {
|
if (!terminalWorkspaceMenuOpen) {
|
||||||
setTerminalWorkspaceMenuPosition(null);
|
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.
|
// 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 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" : ""}`;
|
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 = {
|
const modalStyle = {
|
||||||
...(keyboardOverlap > 0
|
...(keyboardOverlap > 0
|
||||||
? {
|
? {
|
||||||
@@ -2739,14 +2756,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
className="terminal-workspace-picker-menu"
|
className="terminal-workspace-picker-menu"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={t("terminal.selectWorkspace", "Select terminal workspace")}
|
aria-label={t("terminal.selectWorkspace", "Select terminal workspace")}
|
||||||
style={terminalWorkspaceMenuPosition
|
style={{
|
||||||
? {
|
...(terminalWorkspaceMenuPosition
|
||||||
top: terminalWorkspaceMenuPosition.top,
|
? {
|
||||||
left: terminalWorkspaceMenuPosition.left,
|
top: terminalWorkspaceMenuPosition.top,
|
||||||
width: terminalWorkspaceMenuPosition.width,
|
left: terminalWorkspaceMenuPosition.left,
|
||||||
maxHeight: terminalWorkspaceMenuPosition.maxHeight,
|
width: terminalWorkspaceMenuPosition.width,
|
||||||
}
|
maxHeight: terminalWorkspaceMenuPosition.maxHeight,
|
||||||
: undefined}
|
}
|
||||||
|
: {}),
|
||||||
|
...(terminalWorkspaceMenuFloatingZ ? { zIndex: terminalWorkspaceMenuFloatingZ } : {}),
|
||||||
|
...(!terminalWorkspaceMenuPosition ? { visibility: "hidden", pointerEvents: "none" } : {}),
|
||||||
|
}}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -642,6 +642,126 @@ describe("TerminalModal", () => {
|
|||||||
expect(createTab).toHaveBeenCalledWith({ cwd: "/repo/.worktrees/duplicate", title: "FN-9998" });
|
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 () => {
|
it("keeps floating and mobile worktree menus reachable and dismissible without orphaned controls", async () => {
|
||||||
const createTab = vi.fn().mockResolvedValue(defaultTab);
|
const createTab = vi.fn().mockResolvedValue(defaultTab);
|
||||||
mockUseTerminalSessions.mockReturnValue({
|
mockUseTerminalSessions.mockReturnValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user