feat: mobile keyboard input visibility - scroll inputs into view when keyboard opens

- Add KeyboardHandler module using visualViewport API for keyboard detection
- Set --keyboard-height CSS variable and toggle .keyboard-visible class
- Auto-scroll focused inputs into view after keyboard appears (350ms delay)
- Update viewport meta with interactive-widget=resizes-content for Android
- Add scroll-margin on inputs for tablet/phone breakpoints
- Resize modal-content and modal-body when keyboard visible on mobile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-31 07:51:29 +01:00
co-authored by Claude Opus 4.5
parent 5b4f79a3a5
commit fd30040289
6 changed files with 220 additions and 7 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ When user says "COM":
1. Increment version in BOTH `package.json` AND `CLAUDE.md`
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
**Version**: 0.1450 (must match `package.json`)
**Version**: 0.1451 (must match `package.json`)
## Project Overview
@@ -24,7 +24,7 @@ Claudeman is a Claude Code session manager with web interface and autonomous Ral
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. Note: `src/tui` is excluded from compilation (legacy/deprecated code path).
**Requirements**: Node.js 18+, Claude CLI, GNU Screen
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claudeman",
"version": "0.1450",
"version": "0.1451",
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+2 -1
View File
@@ -133,8 +133,9 @@ const MAX_TRACKED_AGENTS = 500; // Maximum agents to track (LRU eviction when ex
const INTERNAL_AGENT_PATTERNS = [
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
/^Suggest what user might/i, // Suggestion mode prompt variant
/^aprompt$/i, // Internal prompt agent
/aprompt/i, // Internal prompt agent (anywhere in string)
/^a\s?prompt/i, // Variants of internal prompt agent
/^prompt$/i, // Just "prompt"
];
// Minimum description length - very short descriptions are likely internal or malformed
+152
View File
@@ -178,6 +178,156 @@ function getEventCoords(e) {
return { clientX: e.clientX, clientY: e.clientY };
}
// ============================================================================
// Mobile Keyboard Handler
// ============================================================================
/**
* KeyboardHandler - Detects virtual keyboard visibility and scrolls inputs into view.
* Uses visualViewport API (works on iOS Safari and Android Chrome).
* Sets CSS custom property --keyboard-height and toggles .keyboard-visible class.
*/
const KeyboardHandler = {
initialViewportHeight: null,
isKeyboardVisible: false,
keyboardHeight: 0,
resizeHandler: null,
scrollHandler: null,
/** Initialize keyboard detection */
init() {
// Only initialize on touch devices
if (!MobileDetection.isTouchDevice()) return;
const vv = window.visualViewport;
if (!vv) return;
// Store initial height (update on orientation change)
this.initialViewportHeight = vv.height;
// Create bound handlers for cleanup
this.resizeHandler = this.handleResize.bind(this);
this.scrollHandler = this.handleVisualViewportScroll.bind(this);
// Listen for visualViewport resize (keyboard open/close)
vv.addEventListener('resize', this.resizeHandler);
vv.addEventListener('scroll', this.scrollHandler);
// Update initial height on orientation change
window.addEventListener('orientationchange', () => {
setTimeout(() => {
if (window.visualViewport) {
this.initialViewportHeight = window.visualViewport.height;
}
}, 200);
});
// Focus event handling for scrolling inputs into view
document.addEventListener('focusin', this.handleFocusIn.bind(this));
},
/** Handle visualViewport resize - detect keyboard */
handleResize() {
const vv = window.visualViewport;
if (!vv) return;
const heightDiff = this.initialViewportHeight - vv.height;
const threshold = 150; // Minimum height difference to consider keyboard open
if (heightDiff > threshold) {
this.updateKeyboardState(true, heightDiff);
} else {
this.updateKeyboardState(false, 0);
}
},
/** Handle visualViewport scroll - keep fixed elements positioned on iOS */
handleVisualViewportScroll() {
const vv = window.visualViewport;
if (!vv || !this.isKeyboardVisible) return;
// Update CSS property for any elements that need to track viewport offset
document.documentElement.style.setProperty('--viewport-offset-top', `${vv.offsetTop}px`);
},
/** Update keyboard visibility state */
updateKeyboardState(visible, height) {
if (this.isKeyboardVisible === visible && this.keyboardHeight === height) return;
this.isKeyboardVisible = visible;
this.keyboardHeight = height;
// Update CSS custom property
document.documentElement.style.setProperty('--keyboard-height', `${height}px`);
// Toggle class for CSS targeting
document.body.classList.toggle('keyboard-visible', visible);
// Dispatch custom event for any listeners
window.dispatchEvent(new CustomEvent('keyboardchange', {
detail: { visible, height }
}));
},
/** Handle focus on input elements - scroll into view after keyboard appears */
handleFocusIn(e) {
const target = e.target;
if (!this.isInputElement(target)) return;
// Wait for keyboard to appear before scrolling
setTimeout(() => {
this.scrollInputIntoView(target);
}, 350);
},
/** Check if element is an input that triggers keyboard */
isInputElement(el) {
if (!el) return false;
const tagName = el.tagName?.toLowerCase();
return (
tagName === 'input' ||
tagName === 'textarea' ||
tagName === 'select' ||
el.isContentEditable
);
},
/** Scroll input into view above the keyboard */
scrollInputIntoView(input) {
const vv = window.visualViewport;
// Find scrollable container (modal-body or main viewport)
const modalBody = input.closest('.modal-body');
const scrollContainer = modalBody || document.scrollingElement || document.body;
if (vv) {
const rect = input.getBoundingClientRect();
const visibleHeight = vv.height;
const inputBottom = rect.bottom;
const inputTop = rect.top;
// If input is in bottom 40% of visible area or below it, scroll up
if (inputBottom > visibleHeight * 0.6 || inputTop > visibleHeight * 0.5) {
// For modal body, scroll within the modal
if (modalBody) {
const inputOffsetTop = input.offsetTop;
const targetScroll = inputOffsetTop - (visibleHeight * 0.3);
modalBody.scrollTo({
top: Math.max(0, targetScroll),
behavior: 'smooth'
});
} else {
// For page-level scroll
input.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}
} else {
// Fallback for browsers without visualViewport
input.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}
};
// ============================================================================
// Accessibility: Focus Trap for Modals
// ============================================================================
@@ -884,6 +1034,8 @@ class ClaudemanApp {
init() {
// Initialize mobile detection first (adds device classes to body)
MobileDetection.init();
// Initialize keyboard handler for mobile input visibility
KeyboardHandler.init();
this.initTerminal();
this.loadFontSize();
this.applyHeaderVisibilitySettings();
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<meta name="description" content="Claude Code session manager with web interface">
<meta name="theme-color" content="#0a0a0a">
<title>Claudeman</title>
+62 -2
View File
@@ -7879,6 +7879,17 @@ kbd {
.project-insights-panel {
display: none;
}
/* Tablet Keyboard Input Visibility */
input, textarea, select, [contenteditable] {
scroll-margin-bottom: 200px;
scroll-margin-top: 80px;
}
.keyboard-visible .modal-body {
max-height: calc(60vh - var(--keyboard-height) / 2);
overflow-y: auto;
}
}
/* ============================================================================
@@ -7957,6 +7968,7 @@ kbd {
/* App container - prevent scrolling, strict layout */
.app {
overflow: hidden;
position: relative;
}
/* Ultra-compact session tabs */
@@ -8015,21 +8027,25 @@ kbd {
height: 14px;
}
/* Clean dark toolbar - simple black/gray with white text */
/* Clean dark toolbar - overlaps terminal to hide Claude's status/input */
.toolbar {
position: absolute;
bottom: calc(30px + var(--safe-area-bottom));
left: 0;
right: 0;
height: 40px;
min-height: 40px;
max-height: 40px;
padding: 0.3rem 0.4rem;
padding-left: calc(0.4rem + var(--safe-area-left));
padding-right: calc(0.4rem + var(--safe-area-right));
padding-bottom: calc(0.3rem + var(--safe-area-bottom));
flex-wrap: nowrap;
gap: 0.3rem;
align-items: center;
justify-content: space-between;
background: #111111;
border-top: 1px solid rgba(255, 255, 255, 0.1);
z-index: 50;
}
/* Show case selector in center */
@@ -8389,11 +8405,55 @@ kbd {
padding-right: var(--safe-area-right);
padding-bottom: var(--safe-area-bottom);
}
/* -------------------------------------------------------------------------
Mobile Keyboard Input Visibility
------------------------------------------------------------------------- */
/* Scroll margin for inputs - ensures they scroll above keyboard */
input, textarea, select, [contenteditable] {
scroll-margin-bottom: 250px;
scroll-margin-top: 100px;
}
/* Modal adjustments when keyboard is visible */
.keyboard-visible .modal-content {
/* Don't take full height - resize with keyboard */
height: auto;
max-height: calc(100dvh - var(--keyboard-height) - var(--safe-area-top));
/* Anchor to top */
top: var(--safe-area-top);
bottom: auto;
}
.keyboard-visible .modal-body {
/* Limit modal body height when keyboard up - make room for header/footer */
max-height: calc(50dvh - var(--keyboard-height) / 3);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* Ensure modal footer stays visible above keyboard */
.keyboard-visible .modal-footer,
.keyboard-visible .wizard-footer,
.keyboard-visible .form-actions {
padding-bottom: 0.5rem;
}
/* Smooth transition for keyboard appearance */
.modal-content {
transition: max-height 0.2s ease-out, top 0.2s ease-out;
}
}
/* ============================================================================
Virtual Keyboard Handling
============================================================================ */
:root {
--keyboard-height: 0px;
--viewport-offset-top: 0px;
}
.keyboard-visible .toolbar {
display: none;
}