fix(FN-1043): fix terminal mobile keyboard layout

- Defer fitAddon.fit() via requestAnimationFrame after CSS repaint to ensure container dimensions are settled
- Add overflow:hidden to keyboard-open CSS rule to prevent layout shift
- Add regression tests for xterm re-fit on mobile keyboard open/close
- Update terminal mobile keyboard documentation in README files
This commit is contained in:
gsxdsm
2026-04-06 21:35:46 -07:00
parent 75afc13d54
commit 1c232a17c4
6 changed files with 193 additions and 13 deletions

View File

@@ -114,7 +114,7 @@ Real-time kanban board at `localhost:4040`:
- **Board view** — Drag-and-drop cards between columns, real-time search, column visibility toggle
- **List view** — Group by column or size, inline title editing, duplicate tasks
- **Interactive terminal** — Full PTY-based terminal with xterm.js, multiple tabs, mobile-aware with virtual keyboard handling that keeps the entry area visible above the on-screen keyboard on real devices (Chrome Android and iOS Safari)
- **Interactive terminal** — Full PTY-based terminal with xterm.js, multiple tabs, mobile-aware with virtual keyboard handling that re-fits the terminal view above the on-screen keyboard on real devices (Chrome Android and iOS Safari)
- **Git manager** — View commits/diffs, manage branches, worktree associations, push/pull, inline edit controls for remote name and URL
- **Activity log** — Task lifecycle events, settings changes, filter by type, auto-refresh
- **Files browser** — Browse project root or task worktrees, edit files with syntax highlighting

View File

@@ -126,7 +126,7 @@ Access a fully functional PTY (pseudo-terminal) shell directly from the dashboar
- **Scrollback Buffer**: 5KB of scrollback history with replay on reconnect
- **Reconnection Support**: Automatic reconnect with exponential backoff if connection drops
- **Reliable Prompt Delivery**: Initial shell prompt visible through first keyst press
- **Mobile Keyboard Support**: On mobile devices, the terminal modal automatically adjusts when the on-screen keyboard opens, constraining its height to fit entirely above the keyboard. This ensures the status bar and terminal content remain visible and interactive without bottom overlap. Protected by regression tests for both CSS contract and component behavior.
- **Mobile Keyboard Support**: On mobile devices, the terminal modal automatically adjusts when the on-screen keyboard opens, constraining its height to fit entirely above the keyboard. The xterm.js terminal view re-fits its rows/columns after the modal shrinks (deferred via `requestAnimationFrame` to ensure CSS variable changes are committed before measuring). This ensures the status bar and terminal content remain visible and interactive without bottom overlap. Protected by regression tests for both CSS contract and component behavior.
- **Keyboard Shortcuts**:
- `Ctrl+C` - Send SIGINT to process (copy if text selected)
- `Ctrl+V` - Paste from clipboard

View File

@@ -80,6 +80,11 @@ describe("terminal mobile keyboard layout CSS contract", () => {
expect(ruleBody).toContain(`max-height: ${viewportExpression}`);
});
it("keyboard-open selector includes overflow: hidden to clip content during keyboard transition", () => {
const ruleBody = findKeyboardOpenRule();
expect(ruleBody).toContain("overflow: hidden");
});
it("height and max-height use the identical expression", () => {
const ruleBody = findKeyboardOpenRule();

View File

@@ -101,6 +101,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
const hasInitialCommandRun = useRef<string | false>(false);
const xtermInitializedRef = useRef<string | false>(false);
const resizeRef = useRef<((cols: number, rows: number) => void) | null>(null);
/** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */
const pendingFitRef = useRef<number | null>(null);
// Bump open generation whenever the modal opens so the initialCommand
// effect re-evaluates after a close/reopen cycle (deps may be identical).
@@ -133,18 +135,43 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
// Re-fit xterm when viewport changes affect available height.
// The keyboard opening/closing changes the modal's max-height via
// CSS --keyboard-overlap, so xterm needs to recalculate rows/cols.
if (fitAddonRef.current && xtermRef.current) {
try {
const fitAddon = fitAddonRef.current as InstanceType<typeof import("@xterm/addon-fit").FitAddon>;
fitAddon.fit();
const { cols, rows } = xtermRef.current;
if (resizeRef.current) {
resizeRef.current(cols, rows);
}
} catch {
// Ignore fit errors during viewport transitions
}
//
// IMPORTANT: We must defer fitAddon.fit() until AFTER React has
// committed the state changes above (setKeyboardOverlap, setViewportHeight)
// and the browser has repainted the new modal dimensions. Without this
// deferral, fit() measures the OLD (pre-keyboard) container dimensions
// because React state updates are asynchronous — the inline style with
// the new --keyboard-overlap / --vv-height values hasn't been applied yet.
//
// requestAnimationFrame ensures we run after the next paint, at which
// point the DOM reflects the updated CSS variables and the modal has
// its correct constrained height.
//
// Coalesce rapid events (keyboard animating open) by cancelling any
// previously scheduled rAF before scheduling a new one.
if (pendingFitRef.current !== null) {
cancelAnimationFrame(pendingFitRef.current);
pendingFitRef.current = null;
}
pendingFitRef.current = requestAnimationFrame(() => {
pendingFitRef.current = null;
// Read refs inside the callback to avoid stale closures
const currentFitAddon = fitAddonRef.current;
const currentXterm = xtermRef.current;
const currentResize = resizeRef.current;
if (currentFitAddon && currentXterm) {
try {
const fitAddon = currentFitAddon as InstanceType<typeof import("@xterm/addon-fit").FitAddon>;
fitAddon.fit();
const { cols, rows } = currentXterm;
if (currentResize) {
currentResize(cols, rows);
}
} catch {
// Ignore fit errors during viewport transitions
}
}
});
};
update(); // initial measurement
@@ -154,6 +181,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
return () => {
vv.removeEventListener("resize", update);
vv.removeEventListener("scroll", update);
// Cancel any pending deferred fit
if (pendingFitRef.current !== null) {
cancelAnimationFrame(pendingFitRef.current);
pendingFitRef.current = null;
}
setKeyboardOverlap(0);
setViewportHeight(null);
};

View File

@@ -1807,6 +1807,145 @@ describe("TerminalModal — virtual keyboard overlap handling", () => {
expect(overlay.style.getPropertyValue("--overlay-padding-top")).toBe("");
});
});
describe("xterm re-fit on keyboard open (FN-1043 regression)", () => {
/** Pending rAF callbacks keyed by fake id. */
let rafMap: Map<number, () => void>;
let nextRafId: number;
let originalRAF: typeof window.requestAnimationFrame;
let originalCAF: typeof window.cancelAnimationFrame;
beforeEach(() => {
rafMap = new Map();
nextRafId = 1;
originalRAF = window.requestAnimationFrame;
originalCAF = window.cancelAnimationFrame;
// Capture rAF callbacks with proper cancellation support so the
// coalescing logic (cancel → schedule) works correctly in tests.
window.requestAnimationFrame = ((cb: () => void) => {
const id = nextRafId++;
rafMap.set(id, cb);
return id;
}) as any;
window.cancelAnimationFrame = ((id: number) => {
rafMap.delete(id);
}) as any;
});
afterEach(() => {
window.requestAnimationFrame = originalRAF;
window.cancelAnimationFrame = originalCAF;
});
/** Flush all pending rAF callbacks and clear the map. */
function flushRaf() {
const callbacks = Array.from(rafMap.values());
rafMap.clear();
for (const cb of callbacks) cb();
}
it("defers fitAddon.fit() via requestAnimationFrame after keyboard open", async () => {
const { listeners } = simulateMobileDevice(250);
const mockResizeFn = vi.fn();
mockUseTerminal.mockReturnValue(createMockTerminalState({
connectionStatus: "connected",
resize: mockResizeFn,
}));
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Wait for --keyboard-overlap to be set (initial measurement + rAF)
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
});
// Flush any pending rAF from initial mount
act(() => { flushRaf(); });
// Clear the map before triggering the resize
rafMap.clear();
// Trigger a viewport resize (keyboard opened)
act(() => {
for (const cb of listeners.resize) cb();
});
// The rAF callback should have been scheduled
expect(rafMap.size).toBeGreaterThanOrEqual(1);
// Flush the rAF — this exercises the deferred fit logic.
// In the test env, fitAddonRef.current is null (xterm is mocked
// as a plain object, not wired into refs), so fit() won't actually
// run. We verify the mechanism by confirming rAF was used.
expect(() => {
act(() => { flushRaf(); });
}).not.toThrow();
});
it("coalesces rapid visualViewport resize events into a single rAF callback", async () => {
const { listeners } = simulateMobileDevice(250);
mockUseTerminal.mockReturnValue(createMockTerminalState({
connectionStatus: "connected",
}));
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
});
// Flush any pending rAF from initial mount
act(() => { flushRaf(); });
rafMap.clear();
// Fire multiple rapid resize events (keyboard animating open).
// Each event calls cancelAnimationFrame(previous) then requestAnimationFrame(new),
// so only 1 callback should remain in the map after 3 events.
act(() => {
for (const cb of listeners.resize) cb(); // event 1 → schedule rAF #1
for (const cb of listeners.resize) cb(); // event 2 → cancel #1, schedule rAF #2
for (const cb of listeners.resize) cb(); // event 3 → cancel #2, schedule rAF #3
});
// Only 1 rAF callback should survive (the last one)
expect(rafMap.size).toBe(1);
});
it("reads xterm refs inside the rAF callback (not stale closures)", async () => {
const { listeners } = simulateMobileDevice(250);
mockUseTerminal.mockReturnValue(createMockTerminalState({
connectionStatus: "connected",
resize: vi.fn(),
}));
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
});
// Flush any pending rAF from initial mount
act(() => { flushRaf(); });
rafMap.clear();
// Trigger resize
act(() => {
for (const cb of listeners.resize) cb();
});
// Flush rAF — this should not throw even though xterm refs may be null
// in the test environment. The callback reads refs at call time, not capture time.
expect(() => {
act(() => { flushRaf(); });
}).not.toThrow();
});
});
});
// --- Close/reopen regression tests ---

View File

@@ -8064,6 +8064,10 @@ body {
inherited min-height or flex layout. */
height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px)));
max-height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px)));
/* Clip any content that exceeds the constrained height during
the keyboard-open transition so the terminal doesn't poke out
below the visible area while xterm re-fits. */
overflow: hidden;
}
}