From 8d7abb8c33ff09ca575407352abd97ab9914043c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 13:51:25 -0700 Subject: [PATCH] FN-7447: fix Android terminal mobile viewport sizing Fix Android keyboard-open terminal layout by sizing mobile shells from the visual viewport. - Track visualViewport width in the terminal modal and mobile keyboard detection. - Apply keyboard-constrained mobile terminal sizing even when CSS media queries see a wider layout viewport. - Cover Android visualViewport width behavior with modal, session terminal, and hook regressions. - Document the fix pattern and add a patch changeset for the published Fusion package. Files changed: .../fn-7447-android-mobile-terminal-spacing.md | 7 ++ ...ndroid-mobile-terminal-visual-viewport-width.md | 26 ++++++++ .../dashboard/app/components/TerminalModal.css | 21 +++++- .../dashboard/app/components/TerminalModal.tsx | 8 +++ .../__tests__/SessionTerminal.mobile.test.tsx | 40 +++++++++++- .../components/__tests__/TerminalModal.test.tsx | 76 ++++++++++++++++++++++ .../app/hooks/__tests__/useMobileKeyboard.test.ts | 38 +++++++++++ packages/dashboard/app/hooks/useMobileKeyboard.ts | 10 ++- 8 files changed, 222 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7447 Fusion-Task-Lineage: c01faf0f-842e-4632-bf8f-ae54070c3bf8 Co-authored-by: Fusion (runfusion.ai) --- ...fn-7447-android-mobile-terminal-spacing.md | 7 ++ ...d-mobile-terminal-visual-viewport-width.md | 26 +++++++ .../app/components/TerminalModal.css | 21 ++++- .../app/components/TerminalModal.tsx | 8 ++ .../__tests__/SessionTerminal.mobile.test.tsx | 40 +++++++++- .../__tests__/TerminalModal.test.tsx | 76 +++++++++++++++++++ .../hooks/__tests__/useMobileKeyboard.test.ts | 38 ++++++++++ .../dashboard/app/hooks/useMobileKeyboard.ts | 10 ++- 8 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-7447-android-mobile-terminal-spacing.md create mode 100644 docs/solutions/ui-bugs/android-mobile-terminal-visual-viewport-width.md diff --git a/.changeset/fn-7447-android-mobile-terminal-spacing.md b/.changeset/fn-7447-android-mobile-terminal-spacing.md new file mode 100644 index 0000000000..39758dbc84 --- /dev/null +++ b/.changeset/fn-7447-android-mobile-terminal-spacing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Android mobile terminal spacing while the keyboard is open. +category: fix +dev: Terminal mobile sizing now tracks visualViewport width for keyboard-open xterm fits. diff --git a/docs/solutions/ui-bugs/android-mobile-terminal-visual-viewport-width.md b/docs/solutions/ui-bugs/android-mobile-terminal-visual-viewport-width.md new file mode 100644 index 0000000000..82ddaf2dc9 --- /dev/null +++ b/docs/solutions/ui-bugs/android-mobile-terminal-visual-viewport-width.md @@ -0,0 +1,26 @@ +--- +category: ui-bug +module: dashboard-terminal +tags: [android, mobile, visualViewport, xterm, keyboard] +problem_type: layout-regression +applies_when: Terminal or xterm surfaces render in a mobile shell while Android Chrome reports a visualViewport narrower than the layout viewport. +--- + +# Android terminal spacing with keyboard-open visualViewport width + +## Problem + +Android Chrome can keep `window.innerWidth` at a tablet/layout width while `window.visualViewport.width` is the narrow visible pane with the soft keyboard open. If a terminal switches to mobile UI from the visual viewport but sizes the modal or keyboard tracking from the layout viewport, xterm can fit against a stale wide box and render ASCII filenames with excessive spacing/wrapping at small persisted font sizes such as 10px. + +## Fix pattern + +- Treat touch-primary `visualViewport.width` as part of mobile detection for terminal keyboard handling. +- Publish both `--vv-height` and `--vv-width` while keyboard overlap is present. +- Apply keyboard-constrained terminal sizing on the explicit mobile class, not only inside `@media (max-width: 768px)`, because CSS media queries still see the layout viewport. +- Keep xterm's measured `fontFamily` symbols-free and verify 10px font preferences still trigger fit/resize/refresh. + +## Regression command + +```bash +pnpm --filter @fusion/dashboard exec vitest run app/components/__tests__/TerminalModal.test.tsx app/components/__tests__/SessionTerminal.mobile.test.tsx app/hooks/__tests__/useMobileKeyboard.test.ts --silent=passed-only --reporter=dot +``` diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 9daa83a8ad..f10239adec 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -1350,8 +1350,8 @@ Android folded Chrome can keep a wide layout viewport while visualViewport is th min-width: 0 !important; min-height: 0 !important; width: 100% !important; - width: 100vw !important; - max-width: 100vw !important; + width: var(--vv-width, 100vw) !important; + max-width: var(--vv-width, 100vw) !important; min-height: 100dvh !important; height: 100dvh !important; max-height: 100dvh !important; @@ -1379,6 +1379,23 @@ Android folded Chrome can keep a wide layout viewport while visualViewport is th min-width: 0; } +/* +FNXC:Terminal 2026-07-02-12:30: +The Android keyboard-open recurrence can start with a touch-primary visualViewport narrower than the layout viewport, so `terminal-modal--mobile` must apply the same keyboard-constrained height and current visual viewport width even when CSS media queries still see the wider layout viewport. This keeps xterm's initial 10px fit on contiguous monospace cells instead of a stale wide box that spaces filenames like `AGENTS.md` apart. +*/ +.modal.terminal-modal.terminal-modal--mobile[style*="--keyboard-overlap"] { + min-height: auto !important; + width: var(--vv-width, 100vw) !important; + max-width: var(--vv-width, 100vw) !important; + height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) !important; + max-height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) !important; + overflow: hidden; +} + +.modal-overlay.terminal-modal-overlay:has(.terminal-modal.terminal-modal--mobile[style*="--keyboard-overlap"]) { + align-items: flex-start !important; +} + /* === Terminal Modal Mobile Responsive === */ @media (max-width: 768px) { .modal.terminal-modal { diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index d6c685042e..501ff59d7b 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -470,6 +470,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const [openGeneration, setOpenGeneration] = useState(0); const [keyboardOverlap, setKeyboardOverlap] = useState(0); const [viewportHeight, setViewportHeight] = useState(null); + const [viewportWidth, setViewportWidth] = useState(null); const [terminalPreferences, setTerminalPreferences] = useState(() => readTerminalPreferences(), ); @@ -860,6 +861,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG // This is more reliable than 100dvh on iOS Safari where // the dynamic viewport height behavior varies by browser version. setViewportHeight(vv.height); + /* + FNXC:Terminal 2026-07-02-12:28: + Android Chrome can open the keyboard with a visual viewport narrower than the layout viewport while the terminal footer already shows the persisted 10px preference. Publish the current visual viewport width alongside --vv-height so the fullscreen mobile shell and xterm's first fit measure the visible keyboard-open box before any later orientation, unfold, reconnect, or manual font reset can repair stale wide columns. + */ + setViewportWidth(vv.width); // Scroll the modal so the status bar (bottom edge) stays visible // when the virtual keyboard pushes the viewport up. if (overlap > 0 && modalRef.current?.scrollIntoView) { @@ -916,6 +922,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG } setKeyboardOverlap(0); setViewportHeight(null); + setViewportWidth(null); }; }, [fitAndResizeForSession, isOpen]); @@ -2086,6 +2093,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG // This is more reliable than 100dvh which behaves differently // across Chrome Android vs iOS Safari. "--vv-height": viewportHeight ? `${viewportHeight}px` : undefined, + "--vv-width": viewportWidth ? `${viewportWidth}px` : undefined, } : {}), ...(isDockedMode ? { "--terminal-docked-height": `${dockedHeight}px` } : {}), diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx index 5effc783dc..2caed22ca8 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx @@ -473,11 +473,13 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => { vvHeight, scale = 1, vvOffsetTop = 0, + vvWidth = 375, }: { innerHeight: number; vvHeight: number; scale?: number; vvOffsetTop?: number; + vvWidth?: number; }) { (window as unknown as { ontouchstart: unknown }).ontouchstart = null; Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true }); @@ -489,7 +491,7 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => { }); const listeners: Record void>> = { resize: [], scroll: [] }; const mockVV = { - width: 375, + width: vvWidth, height: vvHeight, offsetTop: vvOffsetTop, offsetLeft: 0, @@ -556,6 +558,42 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => { input.remove(); }); + it("keeps Android keyboard-open 10px metrics on visualViewport mobile width", async () => { + installMatchMedia({ width: false, height: false }); + installVisualViewport({ innerHeight: 700, vvHeight: 320, vvWidth: 390 }); + Object.defineProperty(window, "innerWidth", { value: 900, writable: true, configurable: true }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 700, + configurable: true, + }); + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, fontSize: 10 }), + ); + const input = document.createElement("textarea"); + document.body.appendChild(input); + input.focus(); + + try { + const { ws } = await renderMobile(); + + await waitFor(() => { + const root = screen.getByTestId("cli-terminal-mobile-bar").closest(".cli-session-terminal"); + expect(root).toHaveClass("cli-session-terminal--mobile"); + expect(root).toHaveAttribute("data-keyboard-open", "true"); + const bar = screen.getByTestId("cli-terminal-mobile-bar"); + expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open"); + expect(bar.style.bottom).toBe("380px"); + }); + expect(mockTerm.options.fontSize).toBe(10); + expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string); + await waitFor(() => expect(mockFitAddon.fit).toHaveBeenCalled()); + expect(ws.sent.some((raw) => JSON.parse(raw).type === "resize")).toBe(true); + } finally { + input.remove(); + } + }); + it("keeps initial folded keyboard-open metrics without waiting for unfold", async () => { installVisualViewport({ innerHeight: 300, vvHeight: 300 }); Object.defineProperty(document.documentElement, "clientHeight", { diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index c192558eac..f8bb0d7f80 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -5030,6 +5030,82 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", () } }); + it("fits Android keyboard-open 10px terminal to visual viewport width before any repair event", async () => { + (window as any).ontouchstart = null; + window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10"); + const listeners: Record void>> = { resize: [], scroll: [] }; + const mockVV = { + width: 390, + height: 320, + offsetTop: 0, + offsetLeft: 0, + addEventListener: vi.fn((event: string, cb: () => void) => { + if (listeners[event]) listeners[event].push(cb); + }), + removeEventListener: vi.fn(), + }; + Object.defineProperty(window, "visualViewport", { + value: mockVV, + writable: true, + configurable: true, + }); + Object.defineProperty(window, "innerWidth", { + value: 900, + writable: true, + configurable: true, + }); + Object.defineProperty(window, "innerHeight", { + value: 700, + writable: true, + configurable: true, + }); + Object.defineProperty(document.documentElement, "clientWidth", { + value: 900, + configurable: true, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 700, + configurable: true, + }); + const onDataListeners: Array<(data: string) => void> = []; + const resizeForAndroidKeyboard = vi.fn(); + mockUseTerminal.mockReturnValue(createMockTerminalState({ + connectionStatus: "connected", + resize: resizeForAndroidKeyboard, + onData: vi.fn((cb: (data: string) => void) => { + onDataListeners.push(cb); + return vi.fn(); + }), + onScrollback: vi.fn((cb: (data: string) => void) => { + onDataListeners.push(cb); + return vi.fn(); + }), + })); + + render(); + + await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px")); + await waitFor(() => { + const modal = screen.getByTestId("terminal-modal"); + expect(modal).toHaveClass("terminal-modal--mobile"); + expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("380px"); + expect(modal.style.getPropertyValue("--vv-height")).toBe("320px"); + expect(modal.style.getPropertyValue("--vv-width")).toBe("390px"); + }); + await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0)); + + act(() => { + for (const cb of onDataListeners) { + cb("❯ ls\r\nAGENTS.md CHANGELOG.md README.md eslint.config.mjs tsconfig.placeholder.d.ts  main\r\n"); + } + }); + + await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))); + await waitFor(() => expect(resizeForAndroidKeyboard).toHaveBeenCalledWith(80, 24)); + expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string); + expect(mockTerminalInstance.options.fontSize).toBe(10); + }); + it("treats Android folded visualViewport width as mobile before initial terminal fit", async () => { (window as any).ontouchstart = null; window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10"); diff --git a/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts b/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts index ebef5ccd9b..b46cd519d4 100644 --- a/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts @@ -119,6 +119,44 @@ describe("useMobileKeyboard", () => { }); }); + it("uses touch visualViewport width for Android keyboard-open mobile detection", async () => { + setupMobileVisualViewport({ + innerHeight: 700, + vvHeight: 320, + width: 390, + }); + Object.defineProperty(window, "innerWidth", { + value: 900, + writable: true, + configurable: true, + }); + const originalClientHeight = Object.getOwnPropertyDescriptor(document.documentElement, "clientHeight"); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 700, + configurable: true, + }); + const input = document.createElement("textarea"); + document.body.appendChild(input); + input.focus(); + + try { + const { result } = renderHook(() => useMobileKeyboard()); + + await waitFor(() => { + expect(result.current.keyboardOpen).toBe(true); + expect(result.current.keyboardOverlap).toBe(380); + expect(result.current.viewportHeight).toBe(320); + }); + } finally { + input.remove(); + if (originalClientHeight) { + Object.defineProperty(document.documentElement, "clientHeight", originalClientHeight); + } else { + delete (document.documentElement as { clientHeight?: number }).clientHeight; + } + } + }); + it("updates overlap when visualViewport resize fires on mobile", async () => { const { listeners, mockVV } = setupMobileVisualViewport({ innerHeight: 800, diff --git a/packages/dashboard/app/hooks/useMobileKeyboard.ts b/packages/dashboard/app/hooks/useMobileKeyboard.ts index 1a382111c9..b8b4d97882 100644 --- a/packages/dashboard/app/hooks/useMobileKeyboard.ts +++ b/packages/dashboard/app/hooks/useMobileKeyboard.ts @@ -11,7 +11,15 @@ function isMobileDevice(): boolean { if (typeof window === "undefined") return false; const hasTouchScreen = "ontouchstart" in window || navigator.maxTouchPoints > 0; - const isNarrow = window.innerWidth <= 768; + const visualWidth = window.visualViewport?.width; + /* + FNXC:Terminal 2026-07-02-12:39: + Android Chrome can keep a tablet-sized layout viewport while the active visualViewport is the narrow keyboard-open terminal pane. Keyboard tracking must follow the touch visual width just like `useViewportMode`, or SessionTerminal renders mobile chrome but never lifts/refits its input bar for the initial 10px keyboard-open terminal state. + */ + const effectiveWidth = hasTouchScreen && typeof visualWidth === "number" && visualWidth > 0 + ? Math.min(window.innerWidth, visualWidth) + : window.innerWidth; + const isNarrow = effectiveWidth <= 768; return hasTouchScreen && isNarrow; }