mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-02 10:45:57 +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.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:';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</button>
|
||||
<div class="text-vs-text">
|
||||
<span class="text-vs-accent">${this.session.command}</span>
|
||||
|
|
@ -445,31 +523,68 @@ export class SessionView extends LitElement {
|
|||
${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 class="fixed bottom-4 left-4 right-4 z-40">
|
||||
<!-- First row: Arrow keys -->
|
||||
<div class="flex gap-2 mb-2">
|
||||
<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"
|
||||
@click=${() => this.handleSpecialKey('arrow_up')}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<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"
|
||||
@click=${() => this.handleSpecialKey('arrow_down')}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<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"
|
||||
@click=${() => this.handleSpecialKey('arrow_left')}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<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"
|
||||
@click=${() => this.handleSpecialKey('arrow_right')}
|
||||
>
|
||||
→
|
||||
</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>
|
||||
` : ''}
|
||||
|
||||
|
|
@ -510,46 +625,26 @@ export class SessionView extends LitElement {
|
|||
|
||||
<!-- 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 -->
|
||||
<!-- Send Buttons 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();
|
||||
}
|
||||
}}
|
||||
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=${this.handleMobileInputSendOnly}
|
||||
?disabled=${!this.mobileInputText.trim()}
|
||||
>
|
||||
+TAB
|
||||
SEND
|
||||
</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')}
|
||||
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.handleMobileInputSend}
|
||||
?disabled=${!this.mobileInputText.trim()}
|
||||
>
|
||||
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
|
||||
SEND + ENTER
|
||||
</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 class="text-vs-muted text-xs text-center">
|
||||
SEND: text only • SEND + ENTER: text with enter key
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue