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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7447-android-mobile-terminal-spacing.md
Normal file
7
.changeset/fn-7447-android-mobile-terminal-spacing.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [viewportWidth, setViewportWidth] = useState<number | null>(null);
|
||||
const [terminalPreferences, setTerminalPreferences] = useState<TerminalPreferences>(() =>
|
||||
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` } : {}),
|
||||
|
||||
@@ -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<string, Array<() => 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", {
|
||||
|
||||
@@ -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<string, Array<() => 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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user