From aae68479ee4fe4d5466a3fea53ff2d585ce4a261 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 16 Jun 2025 07:36:52 +0200 Subject: [PATCH] Improve mobile terminal input with virtual keyboard support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/client/components/session-view.ts | 271 +++++++++++++++++++++- 1 file changed, 268 insertions(+), 3 deletions(-) diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index 0938173c..2b773541 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -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 {
+ + + ${this.isMobile ? html` + + ${!this.showMobileInput ? html` +
+ + + + +
+ ` : ''} + + + ${this.showMobileInput ? html` +
+ +
+
Terminal Input
+ +
+ + +
+
+ Type your command(s) below. Supports multiline input. +
+ +
+ + +
+ +
+ + + +
+ + + + +
+ Ctrl+Enter to send • Commands auto-execute with Enter +
+
+
+ ` : ''} + ` : ''} `; }