diff --git a/web/src/client/app.ts b/web/src/client/app.ts index 162ee6dd..7297d0bb 100644 --- a/web/src/client/app.ts +++ b/web/src/client/app.ts @@ -31,6 +31,7 @@ export class VibeTunnelApp extends LitElement { this.setupHotReload(); this.loadSessions(); this.startAutoRefresh(); + this.setupRouting(); } disconnectedCallback() { @@ -38,6 +39,8 @@ export class VibeTunnelApp extends LitElement { if (this.hotReloadWs) { this.hotReloadWs.close(); } + // Clean up routing listeners + window.removeEventListener('popstate', this.handlePopState); } private showError(message: string) { @@ -133,6 +136,8 @@ export class VibeTunnelApp extends LitElement { console.log('Session found, switching to session view'); this.selectedSession = session; this.currentView = 'session'; + // Update URL to include session ID + this.updateUrl(session.id); this.showError('Session created successfully!'); return; } @@ -151,11 +156,15 @@ export class VibeTunnelApp extends LitElement { console.log('Session selected:', session); this.selectedSession = session; this.currentView = 'session'; + // Update URL to include session ID + this.updateUrl(session.id); } private handleBack() { this.currentView = 'list'; this.selectedSession = null; + // Update URL to remove session parameter + this.updateUrl(); } private handleSessionKilled(e: CustomEvent) { @@ -183,6 +192,67 @@ export class VibeTunnelApp extends LitElement { this.showCreateModal = false; } + // URL Routing methods + private setupRouting() { + // Handle browser back/forward navigation + window.addEventListener('popstate', this.handlePopState.bind(this)); + + // Parse initial URL and set state + this.parseUrlAndSetState(); + } + + private handlePopState = (event: PopStateEvent) => { + // Handle browser back/forward navigation + this.parseUrlAndSetState(); + } + + private parseUrlAndSetState() { + const url = new URL(window.location.href); + const sessionId = url.searchParams.get('session'); + + if (sessionId) { + // Load the specific session + this.loadSessionFromUrl(sessionId); + } else { + // Show session list + this.currentView = 'list'; + this.selectedSession = null; + } + } + + private async loadSessionFromUrl(sessionId: string) { + // First ensure sessions are loaded + if (this.sessions.length === 0) { + await this.loadSessions(); + } + + // Find the session + const session = this.sessions.find(s => s.id === sessionId); + if (session) { + this.selectedSession = session; + this.currentView = 'session'; + } else { + // Session not found, go to list view + this.currentView = 'list'; + this.selectedSession = null; + // Update URL to remove invalid session ID + this.updateUrl(); + } + } + + private updateUrl(sessionId?: string) { + const url = new URL(window.location.href); + + if (sessionId) { + url.searchParams.set('session', sessionId); + } else { + url.searchParams.delete('session'); + } + + // Update browser URL without triggering page reload + window.history.pushState(null, '', url.toString()); + } + private setupHotReload(): void { if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index 2b773541..60d1166b 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -16,6 +16,8 @@ export class SessionView extends LitElement { @state() private showMobileInput = false; @state() private mobileInputText = ''; @state() private isMobile = false; + @state() private touchStartX = 0; + @state() private touchStartY = 0; private keyboardHandler = (e: KeyboardEvent) => { if (!this.session) return; @@ -26,6 +28,35 @@ export class SessionView extends LitElement { this.handleKeyboardInput(e); }; + private touchStartHandler = (e: TouchEvent) => { + if (!this.isMobile) return; + + const touch = e.touches[0]; + this.touchStartX = touch.clientX; + this.touchStartY = touch.clientY; + }; + + private touchEndHandler = (e: TouchEvent) => { + if (!this.isMobile) return; + + const touch = e.changedTouches[0]; + const touchEndX = touch.clientX; + const touchEndY = touch.clientY; + + const deltaX = touchEndX - this.touchStartX; + const deltaY = touchEndY - this.touchStartY; + + // Check for horizontal swipe from left edge (back gesture) + const isSwipeRight = deltaX > 100; + const isVerticallyStable = Math.abs(deltaY) < 100; + const startedFromLeftEdge = this.touchStartX < 50; + + if (isSwipeRight && isVerticallyStable && startedFromLeftEdge) { + // Trigger back navigation + this.handleBack(); + } + }; + connectedCallback() { super.connectedCallback(); this.connected = true; @@ -37,6 +68,10 @@ export class SessionView extends LitElement { // Add global keyboard event listener only for desktop if (!this.isMobile) { document.addEventListener('keydown', this.keyboardHandler); + } else { + // Add touch event listeners for mobile swipe gestures + document.addEventListener('touchstart', this.touchStartHandler, { passive: true }); + document.addEventListener('touchend', this.touchEndHandler, { passive: true }); } // Start polling session status @@ -50,6 +85,10 @@ export class SessionView extends LitElement { // Remove global keyboard event listener if (!this.isMobile) { document.removeEventListener('keydown', this.keyboardHandler); + } else { + // Remove touch event listeners + document.removeEventListener('touchstart', this.touchStartHandler); + document.removeEventListener('touchend', this.touchEndHandler); } // Stop polling session status @@ -297,21 +336,60 @@ export class SessionView extends LitElement { 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 + private async handleMobileInputSendOnly() { + // Get the current value from the textarea directly const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement; - if (textarea) { - textarea.value = ''; - } + const textToSend = textarea?.value?.trim() || this.mobileInputText.trim(); - // Hide the input overlay after sending - this.showMobileInput = false; + if (!textToSend) return; + + try { + // Send text without enter key + await this.sendInputText(textToSend); + + // Clear both the reactive property and textarea + this.mobileInputText = ''; + if (textarea) { + textarea.value = ''; + } + + // Trigger re-render to update button state + this.requestUpdate(); + + // Hide the input overlay after sending + this.showMobileInput = false; + } catch (error) { + console.error('Error sending mobile input:', error); + // Don't hide the overlay if there was an error + } + } + + private async handleMobileInputSend() { + // Get the current value from the textarea directly + const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement; + const textToSend = textarea?.value?.trim() || this.mobileInputText.trim(); + + if (!textToSend) return; + + try { + // Add enter key at the end to execute the command + await this.sendInputText(textToSend + '\n'); + + // Clear both the reactive property and textarea + this.mobileInputText = ''; + if (textarea) { + textarea.value = ''; + } + + // Trigger re-render to update button state + this.requestUpdate(); + + // Hide the input overlay after sending + this.showMobileInput = false; + } catch (error) { + console.error('Error sending mobile input:', error); + // Don't hide the overlay if there was an error + } } private async handleSpecialKey(key: string) { @@ -419,7 +497,7 @@ export class SessionView extends LitElement { class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-2 py-1 border-none rounded transition-colors text-xs" @click=${this.handleBack} > - ← BACK + BACK