feat(FN-830): add mobile keyboard-aware terminal positioning

- Implement visual viewport API to reposition terminal above on-screen keyboard
- Add smooth CSS transition for terminal modal height changes on mobile
- Add comprehensive regression tests for keyboard overlap and resize behavior
- Remove stale test files (NewTaskModal, TaskForm) and clean up ActivityLogModal tests
- Document mobile keyboard handling approach in dashboard README
This commit is contained in:
gsxdsm
2026-04-04 00:19:40 -07:00
parent 5b0f0d2943
commit 1d45cdb82b
4 changed files with 357 additions and 1 deletions

View File

@@ -7,6 +7,28 @@ import "@xterm/xterm/css/xterm.css";
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
import type { FitAddon } from "@xterm/addon-fit";
/** Whether the current device is likely mobile (touch-primary, small viewport). */
function isMobileDevice(): boolean {
if (typeof window === "undefined") return false;
const hasTouchScreen =
"ontouchstart" in window || (navigator as any).maxTouchPoints > 0;
const isNarrow = window.innerWidth <= 768;
return hasTouchScreen && isNarrow;
}
/**
* Compute how many CSS pixels the virtual keyboard covers from the bottom
* of the layout viewport. Returns 0 on desktop or when visualViewport is
* unavailable.
*/
function getKeyboardOverlap(): number {
if (typeof window === "undefined" || !window.visualViewport) return 0;
const vv = window.visualViewport;
// The keyboard pushes the visual viewport up so that its bottom edge
// no longer aligns with the layout viewport bottom.
return Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
}
interface TerminalModalProps {
isOpen: boolean;
onClose: () => void;
@@ -33,6 +55,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
const [exitCode, setExitCode] = useState<number | null>(null);
const [xtermReady, setXtermReady] = useState(false);
const [openGeneration, setOpenGeneration] = useState(0);
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
@@ -48,6 +71,27 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
}
}, [isOpen]);
// Track virtual keyboard overlap on mobile so the terminal entry area
// stays visible above the keyboard. On desktop this is a no-op.
useEffect(() => {
if (!isOpen || !isMobileDevice()) return;
const vv = window.visualViewport;
if (!vv) return;
const update = () => setKeyboardOverlap(getKeyboardOverlap());
update(); // initial measurement
vv.addEventListener("resize", update);
vv.addEventListener("scroll", update);
return () => {
vv.removeEventListener("resize", update);
vv.removeEventListener("scroll", update);
setKeyboardOverlap(0);
};
}, [isOpen]);
// Use the session management hook
const {
tabs,
@@ -378,7 +422,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
onClick={handleOverlayClick}
data-testid="terminal-modal-overlay"
>
<div className="modal terminal-modal" data-testid="terminal-modal">
<div
className="modal terminal-modal"
data-testid="terminal-modal"
style={
keyboardOverlap > 0
? { "--keyboard-overlap": `${keyboardOverlap}px` } as React.CSSProperties
: undefined
}
>
{/* Header — on mobile (≤768px) flex-wrap stacks tabs and actions on separate rows;
.terminal-title is hidden; action button labels are hidden (icons only) */}
<div className="terminal-header">

View File

@@ -819,3 +819,298 @@ describe("TerminalModal — mobile layout contract", () => {
expect(mockTerminalInstance.write).toHaveBeenCalledWith("ls\r\n");
});
});
// --- Virtual keyboard overlap handling ---
describe("TerminalModal — virtual keyboard overlap handling", () => {
const mockOnClose = vi.fn();
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockReconnect = vi.fn();
const createMockTerminalState = (overrides = {}) => ({
connectionStatus: "disconnected" as const,
sendInput: mockSendInput,
resize: mockResize,
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
...overrides,
});
const defaultTab = {
id: "tab-1",
sessionId: "test-session-123",
title: "bash",
isActive: true,
createdAt: Date.now(),
};
const defaultSessionState = {
tabs: [defaultTab],
activeTab: defaultTab,
isReady: true,
createTab: vi.fn(),
closeTab: vi.fn(),
setActiveTab: vi.fn(),
updateTabTitle: vi.fn(),
restartActiveTab: vi.fn(),
};
let savedVisualViewport: typeof window.visualViewport;
let savedInnerWidth: typeof window.innerWidth;
let savedOntouchstart: typeof window.ontouchstart;
beforeEach(() => {
vi.clearAllMocks();
mockUseTerminal.mockReturnValue(createMockTerminalState());
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
// Stash originals
savedVisualViewport = window.visualViewport;
savedInnerWidth = window.innerWidth;
savedOntouchstart = window.ontouchstart;
});
afterEach(() => {
// Restore originals
Object.defineProperty(window, "visualViewport", {
value: savedVisualViewport,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerWidth", {
value: savedInnerWidth,
writable: true,
configurable: true,
});
Object.defineProperty(window, "ontouchstart", {
value: savedOntouchstart,
writable: true,
configurable: true,
});
vi.restoreAllMocks();
});
/**
* Helper: simulate a mobile device with a visualViewport.
* The resize/scroll callbacks are captured so tests can fire them.
*/
function simulateMobileDevice(overlapPx: number) {
// Touch device
(window as any).ontouchstart = null; // truthy — "ontouchstart" in window → true
// Narrow viewport
Object.defineProperty(window, "innerWidth", {
value: 375,
writable: true,
configurable: true,
});
// visualViewport mock
const listeners: Record<string, Array<() => void>> = {
resize: [],
scroll: [],
};
const vvHeight = 300; // viewport shrunk by keyboard
const vvOffsetTop = overlapPx > 0 ? 0 : 0; // typically 0 on modern mobile
const mockVV = {
width: 375,
height: vvHeight,
offsetTop: vvOffsetTop,
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,
});
// Override innerHeight to simulate keyboard overlap
// keyboardOverlap = window.innerHeight - vv.offsetTop - vv.height
// For overlapPx > 0: window.innerHeight = vv.offsetTop + vv.height + overlapPx
Object.defineProperty(window, "innerHeight", {
value: vvOffsetTop + vvHeight + overlapPx,
writable: true,
configurable: true,
});
return { listeners, mockVV };
}
it("does not apply --keyboard-overlap when not on a mobile device", async () => {
// Desktop: no touch, wide viewport
delete (window as any).ontouchstart;
Object.defineProperty(window, "innerWidth", {
value: 1440,
writable: true,
configurable: true,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
// No --keyboard-overlap should be set (style should be undefined/empty)
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
});
});
it("applies --keyboard-overlap CSS variable when virtual keyboard is open on mobile", async () => {
const { listeners } = simulateMobileDevice(250); // 250px keyboard overlap
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
const overlap = modal.style.getPropertyValue("--keyboard-overlap");
expect(overlap).toBe("250px");
});
});
it("updates --keyboard-overlap when keyboard height changes", async () => {
const { listeners } = simulateMobileDevice(250);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
});
// Simulate keyboard shrinking (user swiped down partially)
Object.defineProperty(window, "innerHeight", {
value: 300 + 0 + 100, // keyboardOverlap becomes 100
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("100px");
});
});
it("removes --keyboard-overlap when keyboard closes", async () => {
const { listeners } = simulateMobileDevice(250);
const { rerender } = render(
<TerminalModal isOpen={true} onClose={mockOnClose} />,
);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
});
// Keyboard closes → overlap becomes 0
Object.defineProperty(window, "innerHeight", {
value: 300 + 0 + 0,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
// When overlap is 0, the style prop should be undefined (no CSS variable set)
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
});
});
it("clears overlap when modal closes", async () => {
const { listeners } = simulateMobileDevice(250);
const { rerender } = render(
<TerminalModal isOpen={true} onClose={mockOnClose} />,
);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
});
// Close the modal
act(() => {
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
});
// Modal is no longer rendered
expect(screen.queryByTestId("terminal-modal")).toBeNull();
});
it("falls back gracefully when visualViewport is unavailable", async () => {
// Mobile device but no visualViewport API (older browser)
(window as any).ontouchstart = null;
Object.defineProperty(window, "innerWidth", {
value: 375,
writable: true,
configurable: true,
});
Object.defineProperty(window, "visualViewport", {
value: undefined,
writable: true,
configurable: true,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
// No keyboard overlap applied since visualViewport is unavailable
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
});
});
it("registers and cleans up visualViewport listeners on mobile", async () => {
const { mockVV } = simulateMobileDevice(250);
const { unmount } = render(
<TerminalModal isOpen={true} onClose={mockOnClose} />,
);
await waitFor(() => {
expect(mockVV.addEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
expect(mockVV.addEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
});
const resizeCalls = mockVV.addEventListener.mock.calls.filter(
(c: any[]) => c[0] === "resize",
);
const scrollCalls = mockVV.addEventListener.mock.calls.filter(
(c: any[]) => c[0] === "scroll",
);
unmount();
// Cleanup should remove both listeners
expect(mockVV.removeEventListener).toHaveBeenCalledWith("resize", resizeCalls[0][1]);
expect(mockVV.removeEventListener).toHaveBeenCalledWith("scroll", scrollCalls[0][1]);
});
it("zero overlap on mobile with no keyboard does not set CSS variable", async () => {
simulateMobileDevice(0); // no keyboard
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
});
});
});

View File

@@ -7420,6 +7420,14 @@ body {
padding: 10px 12px;
font-size: 12px;
}
/* When a virtual keyboard is open, shrink the modal from the bottom so
the terminal entry area (status bar, command prompt) remains visible
above the keyboard. The JS component sets --keyboard-overlap to the
number of pixels the keyboard covers. */
.terminal-modal[style*="--keyboard-overlap"] {
max-height: calc(100dvh - var(--keyboard-overlap, 0px));
}
}