From b450dd493df4de861926fd7989010695d642d7c0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 09:13:48 -0700 Subject: [PATCH] FN-7282: fix mobile terminal workspace menu Keep the terminal workspace picker visible and usable on narrow mobile screens. - Portal the workspace listbox to the viewport and position it from the trigger with visual viewport bounds. - Constrain the menu width and height with scroll-safe mobile CSS while preserving workspace selection actions. - Cover mobile rendering, loading/error picker behavior, and document the viewport-safe menu expectation. Files changed: .../fn-7282-mobile-terminal-worktree-menu.md | 7 ++ docs/dashboard-guide.md | 2 +- .../dashboard/app/components/TerminalModal.css | 21 +++-- .../dashboard/app/components/TerminalModal.tsx | 101 ++++++++++++++++++++- .../components/__tests__/TerminalModal.test.tsx | 72 ++++++++++++++- 5 files changed, 186 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-7282 Fusion-Task-Lineage: ca6f7b2b-2d04-41c3-b2eb-d3195e3a1ff5 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7282-mobile-terminal-worktree-menu.md | 7 ++ docs/dashboard-guide.md | 2 +- .../app/components/TerminalModal.css | 21 ++-- .../app/components/TerminalModal.tsx | 101 +++++++++++++++++- .../__tests__/TerminalModal.test.tsx | 72 ++++++++++++- 5 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 .changeset/fn-7282-mobile-terminal-worktree-menu.md diff --git a/.changeset/fn-7282-mobile-terminal-worktree-menu.md b/.changeset/fn-7282-mobile-terminal-worktree-menu.md new file mode 100644 index 0000000000..5d5e3e0c83 --- /dev/null +++ b/.changeset/fn-7282-mobile-terminal-worktree-menu.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the mobile terminal workspace picker so its menu stays visible and reachable. +category: fix +dev: Portals and viewport-constrains the TerminalModal worktree listbox while preserving tab cwd semantics. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7a900b28bc..18b39d2a71 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -511,7 +511,7 @@ Use the terminal on mobile: Open a terminal in a specific workspace: 1. Open the terminal and use the workspace picker in the terminal header. - Expected outcome: **Project Root** is always available and opens a new tab in the repository root. + Expected outcome: **Project Root** is always available and opens a new tab in the repository root. On narrow mobile screens, the picker menu remains visible, viewport-safe, and scrollable instead of being clipped by the terminal header. 2. Select a task worktree from the **Task Worktrees** list, then choose **Open terminal in selected workspace**. Expected outcome: Fusion opens a new terminal tab with the selected task label and starts the shell in that task worktree. 3. If a task is listed without a live worktree, the task remains visible but disabled and marked **No worktree**. diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index bc3b862462..8b5674bf3f 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -357,6 +357,9 @@ Terminal worktree selection mirrors the file-browser workspace model while stayi FNXC:TerminalWorkspaces 2026-06-29-00:00: Terminal headers must survive many tabs, long task titles, floating narrow widths, and mobile touch controls. Bound tab/picker labels and make menus scroll within the viewport so close/reconnect/keyboard controls and the xterm viewport remain reachable. + +FNXC:TerminalWorkspaces 2026-06-30-00:00: +The worktree listbox must escape the mobile terminal header's clipped overflow. It is portaled to the viewport, positioned from the trigger, and constrained with tokenized gutters so narrow phones can reach and scroll every workspace option without hiding the fast + terminal action. */ .terminal-workspace-picker { position: relative; @@ -427,11 +430,14 @@ Terminal headers must survive many tabs, long task titles, floating narrow width } .terminal-workspace-picker-menu { - position: absolute; - top: calc(100% + var(--space-xs)); - right: 0; - width: min(340px, calc(100vw - var(--space-xl))); - max-height: min(360px, calc(100dvh - 120px)); + --terminal-workspace-menu-width: calc(var(--space-xl) * 14.167); + --terminal-workspace-menu-min-width: calc(var(--space-xl) * 9.167); + --terminal-workspace-menu-height: calc(var(--space-xl) * 15); + position: fixed; + top: calc(var(--space-xl) * 2); + left: var(--space-md); + width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-md) * 2))); + max-height: min(var(--terminal-workspace-menu-height), calc(100dvh - (var(--space-md) * 2))); overflow-y: auto; overscroll-behavior: contain; padding: var(--space-xs); @@ -439,6 +445,7 @@ Terminal headers must survive many tabs, long task titles, floating narrow width border-radius: var(--radius-md); background: var(--card); box-shadow: var(--shadow-lg); + z-index: 5000; } .terminal-workspace-picker-option { @@ -1339,8 +1346,8 @@ Footer reads left-to-right: text-size control, then the relocated Clear/Shortcut } .terminal-workspace-picker-menu { - right: calc(var(--space-xs) * -1); - max-height: min(300px, calc(100dvh - 96px)); + width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-sm) * 2))); + max-height: min(var(--terminal-workspace-menu-height), calc(100dvh - (var(--space-sm) * 2))); -webkit-overflow-scrolling: touch; } diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index b61bb91a81..127bc4bb91 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -81,6 +81,13 @@ interface TerminalFloatPosition { y: number; } +interface TerminalWorkspaceMenuPosition { + top: number; + left: number; + width: number; + maxHeight: number; +} + function readTerminalDisplayMode(projectId?: string): TerminalDisplayMode { if (typeof window === "undefined") return "docked"; const value = window.localStorage.getItem(`fusion:terminal-display-mode-${projectId ?? "default"}`); @@ -469,6 +476,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const terminalRef = useRef(null); const modalRef = useRef(null); const terminalWorkspacePickerRef = useRef(null); + const terminalWorkspaceTriggerRef = useRef(null); + const terminalWorkspaceMenuRef = useRef(null); const overlayMouseDownRef = useRef(false); const xtermRef = useRef(null); const fitAddonRef = useRef(null); @@ -943,6 +952,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG error: terminalWorkspacesError, } = useWorkspaces(projectId); const [terminalWorkspaceMenuOpen, setTerminalWorkspaceMenuOpen] = useState(false); + const [terminalWorkspaceMenuPosition, setTerminalWorkspaceMenuPosition] = useState(null); const [selectedTerminalWorkspaceId, setSelectedTerminalWorkspaceId] = useState("project"); const selectedTerminalWorkspace = useMemo( @@ -974,22 +984,91 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG } }, [selectedTerminalWorkspaceId, terminalWorkspaces]); + const getEffectiveViewport = useCallback(() => { + const visualViewport = window.visualViewport; + if (visualViewport && visualViewport.width > 0 && visualViewport.height > 0) { + return { + width: visualViewport.width, + height: visualViewport.height, + offsetTop: visualViewport.offsetTop, + offsetLeft: visualViewport.offsetLeft, + }; + } + return { width: window.innerWidth, height: window.innerHeight, offsetTop: 0, offsetLeft: 0 }; + }, []); + + const updateTerminalWorkspaceMenuPosition = useCallback(() => { + const trigger = terminalWorkspaceTriggerRef.current; + if (!trigger) return; + + const rect = trigger.getBoundingClientRect(); + const menu = terminalWorkspaceMenuRef.current; + const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); + const rootStyle = getComputedStyle(document.documentElement); + const horizontalGutter = Number.parseFloat(rootStyle.getPropertyValue("--space-md")) || 16; + const verticalGutter = horizontalGutter; + const gap = Number.parseFloat(rootStyle.getPropertyValue("--space-xs")) || 6; + const minWidth = Number.parseFloat(rootStyle.getPropertyValue("--terminal-workspace-menu-min-width")) || 220; + const preferredWidth = Number.parseFloat(rootStyle.getPropertyValue("--terminal-workspace-menu-width")) || 340; + const preferredHeight = Number.parseFloat(rootStyle.getPropertyValue("--terminal-workspace-menu-height")) || 360; + + const measuredWidth = menu?.offsetWidth || Math.max(rect.width, preferredWidth); + const maxWidth = Math.max(viewportWidth - horizontalGutter * 2, minWidth); + const width = Math.min(Math.max(measuredWidth, minWidth), maxWidth); + const measuredHeight = menu?.offsetHeight || preferredHeight; + const maxHeight = Math.max(viewportHeight - verticalGutter * 2, minWidth); + const constrainedHeight = Math.min(measuredHeight, maxHeight); + const triggerTop = rect.top - offsetTop; + const triggerBottom = rect.bottom - offsetTop; + const triggerRight = rect.right - offsetLeft; + const spaceBelow = viewportHeight - triggerBottom; + const spaceAbove = triggerTop; + const openUpward = spaceBelow < constrainedHeight && spaceAbove > spaceBelow; + const left = Math.min( + Math.max(triggerRight - width, horizontalGutter), + viewportWidth - horizontalGutter - width, + ) + offsetLeft; + const top = openUpward + ? Math.max(verticalGutter + offsetTop, triggerTop - constrainedHeight - gap + offsetTop) + : Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalGutter - constrainedHeight); + + setTerminalWorkspaceMenuPosition({ top, left, width, maxHeight: constrainedHeight }); + }, [getEffectiveViewport]); + useEffect(() => { if (!terminalWorkspaceMenuOpen) { + setTerminalWorkspaceMenuPosition(null); return; } const handlePointerDown = (event: PointerEvent) => { const target = event.target; - if (target instanceof Node && terminalWorkspacePickerRef.current?.contains(target)) { + if ( + target instanceof Node && + (terminalWorkspacePickerRef.current?.contains(target) || terminalWorkspaceMenuRef.current?.contains(target)) + ) { return; } setTerminalWorkspaceMenuOpen(false); }; + const handleReposition = () => updateTerminalWorkspaceMenuPosition(); + const frame = requestAnimationFrame(handleReposition); document.addEventListener("pointerdown", handlePointerDown); - return () => document.removeEventListener("pointerdown", handlePointerDown); - }, [terminalWorkspaceMenuOpen]); + window.addEventListener("resize", handleReposition); + window.addEventListener("scroll", handleReposition, true); + const visualViewport = window.visualViewport; + visualViewport?.addEventListener("resize", handleReposition); + visualViewport?.addEventListener("scroll", handleReposition); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("resize", handleReposition); + window.removeEventListener("scroll", handleReposition, true); + visualViewport?.removeEventListener("resize", handleReposition); + visualViewport?.removeEventListener("scroll", handleReposition); + }; + }, [terminalWorkspaceMenuOpen, terminalWorkspaces.length, updateTerminalWorkspaceMenuPosition]); const handleOpenSelectedTerminalWorkspace = useCallback(() => { setTerminalWorkspaceMenuOpen(false); @@ -2048,6 +2127,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG > - {terminalWorkspaceMenuOpen && ( + {terminalWorkspaceMenuOpen && createPortal(
event.stopPropagation()} >
+ , + document.body, )} )} diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index e11b48ca65..8fa4a56042 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -381,21 +381,48 @@ describe("TerminalModal", () => { unmount(); const previousInnerWidth = window.innerWidth; + const previousInnerHeight = window.innerHeight; const previousOntouchstart = window.ontouchstart; - Object.defineProperty(window, "innerWidth", { value: 500, configurable: true }); + Object.defineProperty(window, "innerWidth", { value: 390, configurable: true }); + Object.defineProperty(window, "innerHeight", { value: 720, configurable: true }); Object.defineProperty(window, "ontouchstart", { value: null, configurable: true }); try { render(); const mobileModal = await screen.findByTestId("terminal-modal"); expect(mobileModal).not.toHaveClass("terminal-modal--docked"); expect(mobileModal).not.toHaveClass("terminal-modal--floating"); - fireEvent.click(screen.getByLabelText("Select terminal workspace: Project Root")); - expect(screen.getByRole("listbox", { name: "Select terminal workspace" })).toBeInTheDocument(); + const trigger = screen.getByLabelText("Select terminal workspace: Project Root"); + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 220, + y: 18, + top: 18, + left: 220, + right: 352, + bottom: 54, + width: 132, + height: 36, + toJSON: () => ({}), + } as DOMRect); + fireEvent.click(trigger); + const listbox = screen.getByRole("listbox", { name: "Select terminal workspace" }); + expect(listbox).toBeInTheDocument(); + expect(listbox.parentElement).toBe(document.body); + expect(listbox).toHaveTextContent("Project Root"); + expect(listbox).toHaveTextContent("FN-7253"); + await waitFor(() => { + expect(listbox).toHaveStyle({ position: "fixed" }); + expect(Number.parseFloat(listbox.style.left)).toBeGreaterThanOrEqual(0); + expect(Number.parseFloat(listbox.style.top)).toBeGreaterThanOrEqual(0); + expect(Number.parseFloat(listbox.style.width)).toBeLessThanOrEqual(390); + expect(Number.parseFloat(listbox.style.maxHeight)).toBeLessThanOrEqual(720); + }); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.queryByRole("listbox", { name: "Select terminal workspace" })).toBeNull(); + expect(trigger).not.toHaveAttribute("aria-controls"); expect(mockOnClose).not.toHaveBeenCalled(); } 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 { @@ -404,20 +431,57 @@ describe("TerminalModal", () => { } }); + it("avoids inert workspace picker shells while loading or after workspace fetch errors", async () => { + mockUseWorkspaces.mockReturnValue({ + projectName: "kb", + workspaces: [ + { id: "FN-7253", label: "FN-7253", title: "Loading stays usable", worktree: "/repo/.worktrees/fn-7253", kind: "task" }, + ], + loading: true, + error: null, + }); + + const { rerender } = render(); + fireEvent.click(await screen.findByLabelText("Select terminal workspace: Project Root")); + expect(screen.getByText("Task worktrees (refreshing…)")).toBeInTheDocument(); + expect(screen.getByLabelText("Open terminal in selected workspace")).toBeEnabled(); + + mockUseWorkspaces.mockReturnValue({ + projectName: "kb", + workspaces: [ + { id: "FN-7253", label: "FN-7253", title: "Stale entry hidden on error", worktree: "/repo/.worktrees/fn-7253", kind: "task" }, + ], + loading: false, + error: "failed", + }); + rerender(); + + expect(screen.queryByTestId("terminal-workspace-picker")).toBeNull(); + fireEvent.click(screen.getByLabelText("New terminal")); + expect(defaultSessionState.createTab).toHaveBeenCalledWith(); + }); + it("bounds terminal worktree picker and tab labels so header actions stay reachable", () => { const tabRule = terminalModalCss.match(/\.terminal-tab\s*\{([^}]*)\}/)?.[1] ?? ""; const tabLabelRule = terminalModalCss.match(/\.terminal-tab-label\s*\{([^}]*)\}/)?.[1] ?? ""; const triggerRule = terminalModalCss.match(/\.terminal-workspace-picker-trigger\s*\{([^}]*)\}/)?.[1] ?? ""; const menuRule = terminalModalCss.match(/\.terminal-workspace-picker-menu\s*\{([^}]*)\}/)?.[1] ?? ""; const actionsRule = terminalModalCss.match(/\.terminal-actions\s*\{([^}]*)\}/)?.[1] ?? ""; + const mobileHeaderRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-header\s*\{([^}]*)\}/)?.[1] ?? ""; const mobileRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-workspace-picker-menu\s*\{([^}]*)\}/)?.[1] ?? ""; expect(tabRule).toContain("max-width: min(260px, 42vw);"); expect(tabLabelRule).toContain("text-overflow: ellipsis;"); expect(triggerRule).toContain("width: clamp(112px, 16vw, 220px);"); - expect(menuRule).toContain("max-height: min(360px, calc(100dvh - 120px));"); + expect(menuRule).toContain("position: fixed;"); + expect(menuRule).toContain("width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-md) * 2)));"); + expect(menuRule).toContain("max-height: min(var(--terminal-workspace-menu-height), calc(100dvh - (var(--space-md) * 2)));"); + expect(menuRule).toContain("overflow-y: auto;"); expect(menuRule).toContain("overscroll-behavior: contain;"); expect(actionsRule).toContain("flex: 0 0 auto;"); + expect(mobileHeaderRule).toContain("overflow: hidden;"); + expect(mobileRule).not.toContain("right:"); + expect(mobileRule).toContain("width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-sm) * 2)));"); expect(mobileRule).toContain("-webkit-overflow-scrolling: touch;"); });