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:
gsxdsm
2026-07-02 13:51:25 -07:00
parent 5984f327d3
commit 8d7abb8c33
8 changed files with 222 additions and 4 deletions

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

View File

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

View File

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

View File

@@ -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` } : {}),

View File

@@ -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", {

View File

@@ -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");

View File

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

View File

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