FN-7289: fix folded-phone terminal keyboard spacing
Preserve mobile terminal geometry when folded phones open with the keyboard already visible. - Use layout viewport height when computing keyboard overlap so focused keyboard-open folded postures keep a valid baseline. - Allow keyboard-closed width/posture changes at short folded heights to refresh the terminal viewport baseline. - Add regression coverage for TerminalModal and SessionTerminal folded-phone keyboard spacing and document the recurrence. Files changed: .../fn-7289-mobile-terminal-folded-keyboard.md | 7 ++ .../mobile-terminal-folded-viewport-baseline.md | 11 +- .../dashboard/app/components/TerminalModal.tsx | 20 ++-- .../__tests__/SessionTerminal.mobile.test.tsx | 30 +++++ .../components/__tests__/TerminalModal.test.tsx | 127 +++++++++++++++++++++ 5 files changed, 183 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-7289 Fusion-Task-Lineage: 82134835-ca72-4796-89da-c8d071efc481 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7289-mobile-terminal-folded-keyboard.md
Normal file
7
.changeset/fn-7289-mobile-terminal-folded-keyboard.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix mobile terminal spacing when folded phones open with the keyboard already visible.
|
||||
category: fix
|
||||
dev: TerminalModal now uses layout-viewport height for the initial focused keyboard-open folded posture and tests cover TerminalModal plus SessionTerminal.
|
||||
@@ -10,7 +10,8 @@ symptoms:
|
||||
- "Terminal commands render with excessive inter-character spacing or premature wrapping in folded mobile posture"
|
||||
- "Keyboard-open terminal height/overlap is computed from an earlier unfolded viewport"
|
||||
- "Embedded CLI session terminal input bar is lifted too far after a fold/narrow transition"
|
||||
root_cause: stale_unfolded_visualviewport_baseline
|
||||
- "Terminal spacing fixes itself only after a later unfold/orientation event"
|
||||
root_cause: stale_or_missing_folded_visualviewport_baseline
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
@@ -18,6 +19,7 @@ related_components:
|
||||
- packages/dashboard/app/hooks/useMobileKeyboard.ts
|
||||
- packages/dashboard/app/components/SessionTerminal.tsx
|
||||
- FN-7281
|
||||
- FN-7289
|
||||
tags:
|
||||
- terminal
|
||||
- xterm
|
||||
@@ -33,7 +35,7 @@ tags:
|
||||
|
||||
The terminal has two mobile xterm surfaces: the PTY `TerminalModal` and the embedded `SessionTerminal`. Both depend on visualViewport-derived keyboard metrics before fitting xterm rows/cols. On iOS-style browsers, `innerHeight` can shrink with the keyboard, so the code keeps a baseline viewport height captured while the keyboard is closed.
|
||||
|
||||
A foldable device can first expose an unfolded/wide closed baseline, then settle to a narrower folded baseline before the keyboard opens. If the folded closed sample is shorter than the previous baseline and the baseline only ever grows, the later keyboard-open sample overestimates the overlap. Conversely, a fold/orientation width sample can arrive after xterm's helper textarea is focused and the soft keyboard is already open; if that focused sample replaces the baseline, the keyboard-open height looks closed and clears the terminal CSS variables. Both stale geometries make the terminal fit against the wrong box and can surface as premature wrapping or spaced ASCII such as `p n p m b u i l d`.
|
||||
A foldable device can first expose an unfolded/wide closed baseline, then settle to a narrower folded baseline before the keyboard opens. If the folded closed sample is shorter than the previous baseline and the baseline only ever grows, the later keyboard-open sample overestimates the overlap. Conversely, a fold/orientation width sample can arrive after xterm's helper textarea is focused and the soft keyboard is already open; if that focused sample replaces the baseline, the keyboard-open height looks closed and clears the terminal CSS variables. A recurrence also appears when the terminal first renders after the helper/input is already focused and the soft keyboard is open: there is no prior closed visualViewport sample, so using the shrunken visualViewport as the baseline clears the overlap until a later unfold/orientation event supplies a usable layout. These stale or missing geometries make the terminal fit against the wrong box and can surface as premature wrapping or spaced ASCII such as `p n p m b u i l d`.
|
||||
|
||||
## Solution
|
||||
|
||||
@@ -43,14 +45,15 @@ Treat a keyboard-closed width/posture change as a new baseline, not as keyboard
|
||||
- Preserve the max-observed baseline behavior for same-posture recovery from keyboard-open first samples.
|
||||
- When width changes and the viewport height is a settled folded value, replace the baseline before computing iOS fallback overlap.
|
||||
- Gate that replacement to keyboard-closed samples; if a keyboard-focusable element is active, keep the previous baseline so a focused keyboard-open folded sample cannot zero out the overlap.
|
||||
- When the first sample is already focused and keyboard-open, prefer the layout viewport height (`documentElement.clientHeight` when available) over the shrunken visualViewport height so overlap and `--vv-height` are meaningful before any unfold repair.
|
||||
- Keep xterm's measured font family symbols-free; the fix is viewport measurement, not a letter-spacing or cell-width workaround.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
Guard the invariant at three seams:
|
||||
|
||||
- `TerminalModal.test.tsx` simulates unfolded closed → folded closed → folded keyboard-open and asserts `--keyboard-overlap` / `--vv-height` use the folded baseline. It also covers a focused folded keyboard-open sample so posture re-baselining cannot clear those CSS variables.
|
||||
- `SessionTerminal.mobile.test.tsx` proves the embedded mobile input bar uses the folded baseline instead of the stale unfolded height.
|
||||
- `TerminalModal.test.tsx` simulates initial folded keyboard-open startup with ASCII (`pnpm build`) and prompt glyph output, duplicate visualViewport/orientation events, and asserts `--keyboard-overlap` / `--vv-height` plus xterm resize happen before any unfold. It also simulates unfolded closed → folded closed → folded keyboard-open and covers a focused folded keyboard-open sample so posture re-baselining cannot clear those CSS variables.
|
||||
- `SessionTerminal.mobile.test.tsx` proves the embedded mobile input bar uses layout/folded metrics for both initial focused keyboard-open startup and folded-baseline replacement, while keeping the xterm measured font stack symbols-free.
|
||||
- `useMobileKeyboard.test.ts` covers the shared hook so future consumers inherit the posture-aware baseline behavior.
|
||||
|
||||
Existing terminal tests continue to cover symbols-free xterm font stacks, glyph fallback for Nerd Font/powerline output, duplicate visualViewport resize coalescing, keyboard close clearing, undefined visualViewport, tab-switch scrollback replay, and desktop/tablet terminal modes.
|
||||
|
||||
@@ -189,8 +189,6 @@ const TERMINAL_KEY_LABELS = {
|
||||
pxUnit: "px",
|
||||
} as const;
|
||||
|
||||
const SETTLED_FOLDED_VIEWPORT_MIN_HEIGHT_PX = 480;
|
||||
|
||||
export function ctrlChar(key: string): string {
|
||||
if (!key) {
|
||||
return "";
|
||||
@@ -344,8 +342,9 @@ function getKeyboardOverlap(): number {
|
||||
if (typeof window === "undefined" || !window.visualViewport) return 0;
|
||||
const vv = window.visualViewport;
|
||||
const viewportWidth = vv.width > 0 ? vv.width : window.innerWidth;
|
||||
const viewportHeight = Math.max(window.innerHeight, vv.height);
|
||||
const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
|
||||
const layoutViewportHeight = Math.max(window.innerHeight, document.documentElement?.clientHeight || 0);
|
||||
const viewportHeight = Math.max(layoutViewportHeight, vv.height);
|
||||
const chromeOverlap = Math.max(0, layoutViewportHeight - vv.offsetTop - vv.height);
|
||||
if (chromeOverlap > 0) return chromeOverlap;
|
||||
|
||||
/*
|
||||
@@ -354,8 +353,14 @@ function getKeyboardOverlap(): number {
|
||||
|
||||
FNXC:Terminal 2026-06-30-09:38:
|
||||
A later folded-posture width sample can arrive while xterm's helper textarea is focused and the soft keyboard is already open. Never re-baseline from that focused keyboard-open sample, because it makes the keyboard height look like the closed viewport and clears --keyboard-overlap/--vv-height before the final fit.
|
||||
|
||||
FNXC:Terminal 2026-06-30-10:36:
|
||||
The reported recurrence starts with the folded phone already focused and keyboard-open, so there is no prior closed visualViewport sample to seed the iOS fallback baseline. Prefer the current layout viewport height before falling back to visualViewport height; this preserves --keyboard-overlap/--vv-height and the post-layout xterm fit before any later unfold can repair stale geometry.
|
||||
|
||||
FNXC:Terminal 2026-06-30-11:42:
|
||||
Touch-primary short landscape and folded closed postures can be <=480px tall. A keyboard-closed width/posture sample must replace an unfolded baseline even at that height, while focused keyboard-open samples remain excluded so xterm does not clear overlap before the first correct folded fit.
|
||||
*/
|
||||
if (!isKeyboardFocusableElement(document.activeElement) && hasSettledViewportPostureChange(viewportWidth, viewportHeight)) {
|
||||
if (!isKeyboardFocusableElement(document.activeElement) && hasSettledViewportPostureChange(viewportWidth)) {
|
||||
setInitialViewportBaseline(viewportHeight, viewportWidth);
|
||||
}
|
||||
|
||||
@@ -383,12 +388,11 @@ function setInitialViewportBaseline(height: number, width: number): void {
|
||||
_initialViewportWidth = width;
|
||||
}
|
||||
|
||||
function hasSettledViewportPostureChange(width: number, height: number): boolean {
|
||||
function hasSettledViewportPostureChange(width: number): boolean {
|
||||
return (
|
||||
_initialViewportHeight !== null &&
|
||||
_initialViewportWidth !== null &&
|
||||
Math.abs(width - _initialViewportWidth) >= 1 &&
|
||||
height >= SETTLED_FOLDED_VIEWPORT_MIN_HEIGHT_PX
|
||||
Math.abs(width - _initialViewportWidth) >= 1
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -385,6 +385,7 @@ describe("SessionTerminal (mobile)", () => {
|
||||
// ── Keyboard-open (fixed-footer) + pinch-zoom guard ──────────────────────────
|
||||
describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
|
||||
let savedVisualViewport: typeof window.visualViewport;
|
||||
let savedDocumentElementClientHeight: number;
|
||||
|
||||
function installVisualViewport({
|
||||
innerHeight,
|
||||
@@ -438,6 +439,7 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
|
||||
installMatchMedia(true);
|
||||
_resetInitialViewportHeight();
|
||||
savedVisualViewport = window.visualViewport;
|
||||
savedDocumentElementClientHeight = document.documentElement.clientHeight;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -446,6 +448,10 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||||
value: savedDocumentElementClientHeight,
|
||||
configurable: true,
|
||||
});
|
||||
_resetInitialViewportHeight();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -469,6 +475,30 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("keeps initial folded keyboard-open metrics without waiting for unfold", async () => {
|
||||
installVisualViewport({ innerHeight: 300, vvHeight: 300 });
|
||||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||||
value: 667,
|
||||
configurable: true,
|
||||
});
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
try {
|
||||
await renderMobile();
|
||||
|
||||
await waitFor(() => {
|
||||
const bar = screen.getByTestId("cli-terminal-mobile-bar");
|
||||
expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open");
|
||||
expect(bar.style.bottom).toBe("367px");
|
||||
});
|
||||
expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
|
||||
} finally {
|
||||
input.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-baselines folded iOS viewport before lifting the mobile input bar", async () => {
|
||||
const { listeners, mockVV } = installVisualViewport({ innerHeight: 844, vvHeight: 844 });
|
||||
Object.defineProperty(window, "innerWidth", { value: 700, writable: true, configurable: true });
|
||||
|
||||
@@ -4617,18 +4617,26 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
||||
let savedInnerWidth: typeof window.innerWidth;
|
||||
let savedInnerHeight: typeof window.innerHeight;
|
||||
let savedOntouchstart: typeof window.ontouchstart;
|
||||
let savedDocumentElementClientHeight: number;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetInitialViewportHeight();
|
||||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||||
mockUseWorkspaces.mockReturnValue({
|
||||
projectName: "kb",
|
||||
workspaces: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Stash originals
|
||||
savedVisualViewport = window.visualViewport;
|
||||
savedInnerWidth = window.innerWidth;
|
||||
savedInnerHeight = window.innerHeight;
|
||||
savedOntouchstart = window.ontouchstart;
|
||||
savedDocumentElementClientHeight = document.documentElement.clientHeight;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -4653,6 +4661,10 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||||
value: savedDocumentElementClientHeight,
|
||||
configurable: true,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -4750,6 +4762,71 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
||||
return { listeners, mockVV, initialHeight };
|
||||
}
|
||||
|
||||
it("keeps initial folded keyboard-open terminal metrics before any unfold repair", async () => {
|
||||
const { listeners } = simulateIOSSafari(true, 300);
|
||||
const onDataListeners: Array<(data: string) => void> = [];
|
||||
const resizeForFoldedKeyboard = vi.fn();
|
||||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||||
value: 667,
|
||||
configurable: true,
|
||||
});
|
||||
const helperTextarea = document.createElement("textarea");
|
||||
document.body.appendChild(helperTextarea);
|
||||
helperTextarea.focus();
|
||||
|
||||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||||
connectionStatus: "connected",
|
||||
resize: resizeForFoldedKeyboard,
|
||||
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();
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
|
||||
expect(modal.style.getPropertyValue("--vv-height")).toBe("300px");
|
||||
});
|
||||
await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0));
|
||||
act(() => {
|
||||
for (const cb of onDataListeners) {
|
||||
cb("❯ pnpm build\r\n✔ built packages/dashboard main\r\n");
|
||||
}
|
||||
});
|
||||
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("pnpm build")));
|
||||
await waitFor(() => expect(resizeForFoldedKeyboard).toHaveBeenCalledWith(80, 24));
|
||||
|
||||
resizeForFoldedKeyboard.mockClear();
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
for (const cb of listeners.resize) cb();
|
||||
window.dispatchEvent(new Event("orientationchange"));
|
||||
window.dispatchEvent(new Event("orientationchange"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
|
||||
expect(modal.style.getPropertyValue("--vv-height")).toBe("300px");
|
||||
expect(resizeForFoldedKeyboard).toHaveBeenCalledWith(80, 24);
|
||||
});
|
||||
} finally {
|
||||
helperTextarea.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("detects keyboard on iOS Safari where innerHeight shrinks with visualViewport", async () => {
|
||||
// On iOS Safari, both window.innerHeight and visualViewport.height shrink.
|
||||
// The primary formula (innerHeight - vv.offsetTop - vv.height) returns 0
|
||||
@@ -4878,6 +4955,56 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
||||
});
|
||||
});
|
||||
|
||||
it("re-baselines keyboard-closed folded landscape samples below 480px", async () => {
|
||||
const { listeners, mockVV } = simulateIOSSafari(false, 844);
|
||||
Object.defineProperty(mockVV, "width", { value: 700, writable: true, configurable: true });
|
||||
Object.defineProperty(window, "innerWidth", { value: 700, writable: true, configurable: true });
|
||||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||||
value: 844,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
|
||||
Object.defineProperty(window, "innerHeight", { value: 375, writable: true, configurable: true });
|
||||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||||
value: 375,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "width", { value: 375, writable: true, configurable: true });
|
||||
Object.defineProperty(mockVV, "height", { value: 375, writable: true, configurable: true });
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerHeight", { value: 250, writable: true, configurable: true });
|
||||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||||
value: 250,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", { value: 250, writable: true, configurable: true });
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("125px");
|
||||
expect(modal.style.getPropertyValue("--vv-height")).toBe("250px");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not re-baseline folded viewport width changes from focused keyboard-open samples", async () => {
|
||||
const { listeners, mockVV } = simulateIOSSafari(false, 844);
|
||||
Object.defineProperty(mockVV, "width", { value: 700, writable: true, configurable: true });
|
||||
|
||||
Reference in New Issue
Block a user