mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Add URL routing and enhanced mobile terminal controls
- Implement browser URL routing with session ID parameters - Add browser back/forward navigation support - Auto-load session view from URL on page refresh - Add mobile swipe-back gesture from left edge - Reorganize mobile controls: arrow keys + special keys in normal view - Simplify text overlay to show only SEND and SEND+ENTER buttons - Remove left arrow from BACK button for cleaner look 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ac13030b52
commit
fce59c3df6
2 changed files with 235 additions and 70 deletions
|
|
@ -31,6 +31,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
this.setupHotReload();
|
this.setupHotReload();
|
||||||
this.loadSessions();
|
this.loadSessions();
|
||||||
this.startAutoRefresh();
|
this.startAutoRefresh();
|
||||||
|
this.setupRouting();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
|
@ -38,6 +39,8 @@ export class VibeTunnelApp extends LitElement {
|
||||||
if (this.hotReloadWs) {
|
if (this.hotReloadWs) {
|
||||||
this.hotReloadWs.close();
|
this.hotReloadWs.close();
|
||||||
}
|
}
|
||||||
|
// Clean up routing listeners
|
||||||
|
window.removeEventListener('popstate', this.handlePopState);
|
||||||
}
|
}
|
||||||
|
|
||||||
private showError(message: string) {
|
private showError(message: string) {
|
||||||
|
|
@ -133,6 +136,8 @@ export class VibeTunnelApp extends LitElement {
|
||||||
console.log('Session found, switching to session view');
|
console.log('Session found, switching to session view');
|
||||||
this.selectedSession = session;
|
this.selectedSession = session;
|
||||||
this.currentView = 'session';
|
this.currentView = 'session';
|
||||||
|
// Update URL to include session ID
|
||||||
|
this.updateUrl(session.id);
|
||||||
this.showError('Session created successfully!');
|
this.showError('Session created successfully!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -151,11 +156,15 @@ export class VibeTunnelApp extends LitElement {
|
||||||
console.log('Session selected:', session);
|
console.log('Session selected:', session);
|
||||||
this.selectedSession = session;
|
this.selectedSession = session;
|
||||||
this.currentView = 'session';
|
this.currentView = 'session';
|
||||||
|
// Update URL to include session ID
|
||||||
|
this.updateUrl(session.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleBack() {
|
private handleBack() {
|
||||||
this.currentView = 'list';
|
this.currentView = 'list';
|
||||||
this.selectedSession = null;
|
this.selectedSession = null;
|
||||||
|
// Update URL to remove session parameter
|
||||||
|
this.updateUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSessionKilled(e: CustomEvent) {
|
private handleSessionKilled(e: CustomEvent) {
|
||||||
|
|
@ -183,6 +192,67 @@ export class VibeTunnelApp extends LitElement {
|
||||||
this.showCreateModal = false;
|
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 {
|
private setupHotReload(): void {
|
||||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export class SessionView extends LitElement {
|
||||||
@state() private showMobileInput = false;
|
@state() private showMobileInput = false;
|
||||||
@state() private mobileInputText = '';
|
@state() private mobileInputText = '';
|
||||||
@state() private isMobile = false;
|
@state() private isMobile = false;
|
||||||
|
@state() private touchStartX = 0;
|
||||||
|
@state() private touchStartY = 0;
|
||||||
|
|
||||||
private keyboardHandler = (e: KeyboardEvent) => {
|
private keyboardHandler = (e: KeyboardEvent) => {
|
||||||
if (!this.session) return;
|
if (!this.session) return;
|
||||||
|
|
@ -26,6 +28,35 @@ export class SessionView extends LitElement {
|
||||||
this.handleKeyboardInput(e);
|
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() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
|
|
@ -37,6 +68,10 @@ export class SessionView extends LitElement {
|
||||||
// Add global keyboard event listener only for desktop
|
// Add global keyboard event listener only for desktop
|
||||||
if (!this.isMobile) {
|
if (!this.isMobile) {
|
||||||
document.addEventListener('keydown', this.keyboardHandler);
|
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
|
// Start polling session status
|
||||||
|
|
@ -50,6 +85,10 @@ export class SessionView extends LitElement {
|
||||||
// Remove global keyboard event listener
|
// Remove global keyboard event listener
|
||||||
if (!this.isMobile) {
|
if (!this.isMobile) {
|
||||||
document.removeEventListener('keydown', this.keyboardHandler);
|
document.removeEventListener('keydown', this.keyboardHandler);
|
||||||
|
} else {
|
||||||
|
// Remove touch event listeners
|
||||||
|
document.removeEventListener('touchstart', this.touchStartHandler);
|
||||||
|
document.removeEventListener('touchend', this.touchEndHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop polling session status
|
// Stop polling session status
|
||||||
|
|
@ -297,21 +336,60 @@ export class SessionView extends LitElement {
|
||||||
this.mobileInputText = textarea.value;
|
this.mobileInputText = textarea.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleMobileInputSend() {
|
private async handleMobileInputSendOnly() {
|
||||||
if (!this.mobileInputText.trim()) return;
|
// Get the current value from the textarea directly
|
||||||
|
|
||||||
// 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;
|
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
|
||||||
if (textarea) {
|
const textToSend = textarea?.value?.trim() || this.mobileInputText.trim();
|
||||||
textarea.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the input overlay after sending
|
if (!textToSend) return;
|
||||||
this.showMobileInput = false;
|
|
||||||
|
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) {
|
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"
|
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}
|
@click=${this.handleBack}
|
||||||
>
|
>
|
||||||
← BACK
|
BACK
|
||||||
</button>
|
</button>
|
||||||
<div class="text-vs-text">
|
<div class="text-vs-text">
|
||||||
<span class="text-vs-accent">${this.session.command}</span>
|
<span class="text-vs-accent">${this.session.command}</span>
|
||||||
|
|
@ -445,31 +523,68 @@ export class SessionView extends LitElement {
|
||||||
${this.isMobile ? html`
|
${this.isMobile ? html`
|
||||||
<!-- Quick Action Buttons (only when overlay is closed) -->
|
<!-- Quick Action Buttons (only when overlay is closed) -->
|
||||||
${!this.showMobileInput ? html`
|
${!this.showMobileInput ? html`
|
||||||
<div class="fixed bottom-4 left-4 right-4 flex gap-2 z-40">
|
<div class="fixed bottom-4 left-4 right-4 z-40">
|
||||||
<button
|
<!-- First row: Arrow keys -->
|
||||||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none rounded transition-colors text-sm"
|
<div class="flex gap-2 mb-2">
|
||||||
@click=${() => this.handleSpecialKey('\t')}
|
<button
|
||||||
>
|
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||||
TAB
|
@click=${() => this.handleSpecialKey('arrow_up')}
|
||||||
</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"
|
</button>
|
||||||
@click=${() => this.handleSpecialKey('escape')}
|
<button
|
||||||
>
|
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||||
ESC
|
@click=${() => this.handleSpecialKey('arrow_down')}
|
||||||
</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"
|
</button>
|
||||||
@click=${() => this.handleSpecialKey('\x03')}
|
<button
|
||||||
>
|
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||||
^C
|
@click=${() => this.handleSpecialKey('arrow_left')}
|
||||||
</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"
|
</button>
|
||||||
@click=${this.handleMobileInputToggle}
|
<button
|
||||||
>
|
class="flex-1 bg-vs-muted text-vs-bg hover:bg-vs-accent font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||||
TYPE
|
@click=${() => this.handleSpecialKey('arrow_right')}
|
||||||
</button>
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Second row: Special keys -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<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.handleSpecialKey('\t')}
|
||||||
|
>
|
||||||
|
TAB
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||||
|
@click=${() => this.handleSpecialKey('enter')}
|
||||||
|
>
|
||||||
|
ENTER
|
||||||
|
</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>
|
||||||
|
<button
|
||||||
|
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-3 py-2 border-none rounded transition-colors text-sm"
|
||||||
|
@click=${this.handleMobileInputToggle}
|
||||||
|
>
|
||||||
|
TYPE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
|
@ -510,46 +625,26 @@ export class SessionView extends LitElement {
|
||||||
|
|
||||||
<!-- Controls - Fixed above keyboard -->
|
<!-- 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);">
|
<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 -->
|
<!-- Send Buttons Row -->
|
||||||
<div class="flex gap-2 mb-3">
|
<div class="flex gap-2 mb-3">
|
||||||
<button
|
<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"
|
class="flex-1 bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
|
||||||
@click=${() => {
|
@click=${this.handleMobileInputSendOnly}
|
||||||
this.mobileInputText += '\t';
|
?disabled=${!this.mobileInputText.trim()}
|
||||||
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
|
|
||||||
if (textarea) {
|
|
||||||
textarea.value = this.mobileInputText;
|
|
||||||
textarea.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
+TAB
|
SEND
|
||||||
</button>
|
</button>
|
||||||
<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"
|
class="flex-1 bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-3 border-none rounded transition-colors text-sm font-bold"
|
||||||
@click=${() => this.handleSpecialKey('escape')}
|
@click=${this.handleMobileInputSend}
|
||||||
|
?disabled=${!this.mobileInputText.trim()}
|
||||||
>
|
>
|
||||||
ESC
|
SEND + ENTER
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="text-vs-muted text-xs text-center">
|
||||||
Ctrl+Enter to send • Commands auto-execute with Enter
|
SEND: text only • SEND + ENTER: text with enter key
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue