Improve mobile terminal input with virtual keyboard support

- Move controls above virtual keyboard using Visual Viewport API
- Dynamically adjust textarea height when keyboard appears/disappears
- Add smooth transitions for keyboard show/hide
- Prevent textarea from stretching behind buttons
- Clean up event listeners properly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-16 07:36:52 +02:00
parent 975705ae80
commit aae68479ee

View file

@ -13,6 +13,9 @@ export class SessionView extends LitElement {
@state() private connected = false;
@state() private player: any = null;
@state() private sessionStatusInterval: number | null = null;
@state() private showMobileInput = false;
@state() private mobileInputText = '';
@state() private isMobile = false;
private keyboardHandler = (e: KeyboardEvent) => {
if (!this.session) return;
@ -27,8 +30,14 @@ export class SessionView extends LitElement {
super.connectedCallback();
this.connected = true;
// Add global keyboard event listener
document.addEventListener('keydown', this.keyboardHandler);
// Detect mobile device
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth <= 768;
// Add global keyboard event listener only for desktop
if (!this.isMobile) {
document.addEventListener('keydown', this.keyboardHandler);
}
// Start polling session status
this.startSessionStatusPolling();
@ -39,7 +48,9 @@ export class SessionView extends LitElement {
this.connected = false;
// Remove global keyboard event listener
document.removeEventListener('keydown', this.keyboardHandler);
if (!this.isMobile) {
document.removeEventListener('keydown', this.keyboardHandler);
}
// Stop polling session status
this.stopSessionStatusPolling();
@ -188,6 +199,145 @@ export class SessionView extends LitElement {
this.dispatchEvent(new CustomEvent('back'));
}
// Mobile input methods
private handleMobileInputToggle() {
this.showMobileInput = !this.showMobileInput;
if (this.showMobileInput) {
// Focus the textarea after a short delay to ensure it's rendered
setTimeout(() => {
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
if (textarea) {
textarea.focus();
this.adjustTextareaForKeyboard();
}
}, 100);
} else {
// Clean up viewport listener when closing overlay
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
if (textarea && (textarea as any)._viewportCleanup) {
(textarea as any)._viewportCleanup();
}
}
}
private adjustTextareaForKeyboard() {
// Adjust the layout when virtual keyboard appears
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
const controls = this.querySelector('#mobile-controls') as HTMLElement;
if (!textarea || !controls) return;
const adjustLayout = () => {
const viewportHeight = window.visualViewport?.height || window.innerHeight;
const windowHeight = window.innerHeight;
const keyboardHeight = windowHeight - viewportHeight;
// If keyboard is visible (viewport height is significantly smaller)
if (keyboardHeight > 100) {
// Move controls above the keyboard
controls.style.transform = `translateY(-${keyboardHeight}px)`;
controls.style.transition = 'transform 0.3s ease';
// Calculate available space for textarea
const header = this.querySelector('.flex.items-center.justify-between.p-4.border-b') as HTMLElement;
const headerHeight = header?.offsetHeight || 60;
const controlsHeight = controls?.offsetHeight || 120;
const padding = 48; // Additional padding for spacing
// Available height is viewport height minus header and controls (controls are now above keyboard)
const maxTextareaHeight = viewportHeight - headerHeight - controlsHeight - padding;
const inputArea = textarea.parentElement as HTMLElement;
if (inputArea && maxTextareaHeight > 0) {
// Set the input area to not exceed the available space
inputArea.style.height = `${maxTextareaHeight}px`;
inputArea.style.maxHeight = `${maxTextareaHeight}px`;
inputArea.style.overflow = 'hidden';
// Set textarea height within the container
const labelHeight = 40; // Height of the label above textarea
const textareaMaxHeight = Math.max(maxTextareaHeight - labelHeight, 80);
textarea.style.height = `${textareaMaxHeight}px`;
textarea.style.maxHeight = `${textareaMaxHeight}px`;
}
} else {
// Reset position when keyboard is hidden
controls.style.transform = 'translateY(0px)';
controls.style.transition = 'transform 0.3s ease';
// Reset textarea height and constraints
const inputArea = textarea.parentElement as HTMLElement;
if (inputArea) {
inputArea.style.height = '';
inputArea.style.maxHeight = '';
inputArea.style.overflow = '';
textarea.style.height = '';
textarea.style.maxHeight = '';
}
}
};
// Listen for viewport changes (keyboard show/hide)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', adjustLayout);
// Clean up listener when overlay is closed
const cleanup = () => {
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', adjustLayout);
}
};
// Store cleanup function for later use
(textarea as any)._viewportCleanup = cleanup;
}
// Initial adjustment
setTimeout(adjustLayout, 300);
}
private handleMobileInputChange(e: Event) {
const textarea = e.target as HTMLTextAreaElement;
this.mobileInputText = textarea.value;
}
private async handleMobileInputSend() {
if (!this.mobileInputText.trim()) return;
// Add enter key at the end to execute the command
await this.sendInputText(this.mobileInputText + '\n');
this.mobileInputText = '';
// Update the textarea
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
if (textarea) {
textarea.value = '';
}
// Hide the input overlay after sending
this.showMobileInput = false;
}
private async handleSpecialKey(key: string) {
await this.sendInputText(key);
}
private async sendInputText(text: string) {
if (!this.session) return;
try {
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ text })
});
if (!response.ok) {
console.error('Failed to send input to session');
}
} catch (error) {
console.error('Error sending input:', error);
}
}
private startSessionStatusPolling() {
if (this.sessionStatusInterval) {
clearInterval(this.sessionStatusInterval);
@ -290,6 +440,121 @@ export class SessionView extends LitElement {
<div class="flex-1 bg-black overflow-hidden">
<div id="interactive-terminal" class="w-full h-full"></div>
</div>
<!-- Mobile Input Controls -->
${this.isMobile ? html`
<!-- Quick Action Buttons (only when overlay is closed) -->
${!this.showMobileInput ? html`
<div class="fixed bottom-4 left-4 right-4 flex gap-2 z-40">
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('\t')}
>
TAB
</button>
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('escape')}
>
ESC
</button>
<button
class="bg-vs-error text-vs-text hover:bg-vs-highlight font-mono px-4 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('\x03')}
>
^C
</button>
<button
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none rounded transition-colors text-sm"
@click=${this.handleMobileInputToggle}
>
TYPE
</button>
</div>
` : ''}
<!-- Full-Screen Input Overlay (only when opened) -->
${this.showMobileInput ? html`
<div class="fixed inset-0 bg-vs-bg-secondary bg-opacity-95 z-50 flex flex-col" style="height: 100vh; height: 100dvh;">
<!-- Input Header -->
<div class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0">
<div class="text-vs-text font-mono text-sm">Terminal Input</div>
<button
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
@click=${this.handleMobileInputToggle}
>
×
</button>
</div>
<!-- Input Area with dynamic height -->
<div class="flex-1 p-4 flex flex-col min-h-0">
<div class="text-vs-muted text-sm mb-2 flex-shrink-0">
Type your command(s) below. Supports multiline input.
</div>
<textarea
id="mobile-input-textarea"
class="flex-1 bg-vs-bg text-vs-text border border-vs-border font-mono text-sm p-4 resize-none outline-none"
placeholder="Enter your command here..."
.value=${this.mobileInputText}
@input=${this.handleMobileInputChange}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleMobileInputSend();
}
}}
style="min-height: 120px; margin-bottom: 16px;"
></textarea>
</div>
<!-- Controls - Fixed above keyboard -->
<div id="mobile-controls" class="fixed bottom-0 left-0 right-0 p-4 border-t border-vs-border bg-vs-bg-secondary z-60" style="padding-bottom: max(1rem, env(safe-area-inset-bottom)); transform: translateY(0px);">
<!-- Special Keys Row -->
<div class="flex gap-2 mb-3">
<button
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => {
this.mobileInputText += '\t';
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
if (textarea) {
textarea.value = this.mobileInputText;
textarea.focus();
}
}}
>
+TAB
</button>
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('escape')}
>
ESC
</button>
<button
class="bg-vs-error text-vs-text hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
@click=${() => this.handleSpecialKey('\x03')}
>
^C
</button>
</div>
<!-- Send Button -->
<button
class="w-full bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-6 py-3 border-none rounded transition-colors text-sm font-bold"
@click=${this.handleMobileInputSend}
?disabled=${!this.mobileInputText.trim()}
>
SEND + ENTER
</button>
<div class="text-vs-muted text-xs mt-2 text-center">
Ctrl+Enter to send Commands auto-execute with Enter
</div>
</div>
</div>
` : ''}
` : ''}
</div>
`;
}