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:
gsxdsm
2026-07-13 00:22:41 -07:00
parent 27110ed893
commit 5f432971db
3 changed files with 156 additions and 8 deletions

View 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.

View File

@@ -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

View File

@@ -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({