mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-10 12:05:53 +00:00
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:
parent
975705ae80
commit
aae68479ee
1 changed files with 268 additions and 3 deletions
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue