vibetunnel/web/public/components/session-view.js
Mario Zechner ed3927b4a6 Fix session management and terminal scaling
- Fix session killing via DELETE endpoint instead of wrong POST /kill
- Add proper session card kill animation with ASCII spinner
- Fix double key press issue with keyed directive for session-view
- Implement URL-based navigation for consistent component lifecycle
- Fix session card terminal scaling to show all content at smaller sizes
- Modify ScaleFitAddon to only scale font size for previews, not dimensions
- Add session card loading and killing states with visual feedback
- Remove duplicate event listeners and improve component cleanup

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-16 15:35:11 +02:00

710 lines
No EOL
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Renderer } from '../renderer.js';
let SessionView = class SessionView extends LitElement {
constructor() {
super(...arguments);
this.session = null;
this.connected = false;
this.renderer = null;
this.sessionStatusInterval = null;
this.showMobileInput = false;
this.mobileInputText = '';
this.isMobile = false;
this.touchStartX = 0;
this.touchStartY = 0;
this.loading = false;
this.loadingFrame = 0;
this.loadingInterval = null;
this.keyboardHandler = (e) => {
if (!this.session)
return;
e.preventDefault();
e.stopPropagation();
this.handleKeyboardInput(e);
};
this.touchStartHandler = (e) => {
if (!this.isMobile)
return;
const touch = e.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
};
this.touchEndHandler = (e) => {
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();
}
};
}
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.connected = true;
// Show loading animation if no session yet
if (!this.session) {
this.startLoading();
}
// 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);
}
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
this.startSessionStatusPolling();
}
disconnectedCallback() {
super.disconnectedCallback();
this.connected = false;
// 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
this.stopSessionStatusPolling();
// Stop loading animation
this.stopLoading();
// Cleanup renderer if it exists
if (this.renderer) {
this.renderer.dispose();
this.renderer = null;
}
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
if (this.session) {
this.stopLoading();
this.createInteractiveTerminal();
}
}
updated(changedProperties) {
super.updated(changedProperties);
// Stop loading and create terminal when session becomes available
if (changedProperties.has('session') && this.session && this.loading) {
this.stopLoading();
this.createInteractiveTerminal();
}
// Adjust terminal height for mobile buttons after render
if (changedProperties.has('showMobileInput') || changedProperties.has('isMobile')) {
requestAnimationFrame(() => {
this.adjustTerminalForMobileButtons();
});
}
}
createInteractiveTerminal() {
if (!this.session)
return;
const terminalElement = this.querySelector('#interactive-terminal');
if (!terminalElement)
return;
// Create renderer once and connect to current session
this.renderer = new Renderer(terminalElement);
// Wait a moment for freshly created sessions before connecting
const sessionAge = Date.now() - new Date(this.session.startedAt).getTime();
const delay = sessionAge < 5000 ? 2000 : 0; // 2 second delay if session is less than 5 seconds old
if (delay > 0) {
// Show loading animation during delay for fresh sessions
this.startLoading();
}
setTimeout(() => {
if (this.renderer && this.session) {
this.stopLoading(); // Stop loading before connecting
this.renderer.connectToStream(this.session.id);
}
}, delay);
// Listen for session exit events
terminalElement.addEventListener('session-exit', this.handleSessionExit.bind(this));
}
async handleKeyboardInput(e) {
if (!this.session)
return;
// Don't send input to exited sessions
if (this.session.status === 'exited') {
console.log('Ignoring keyboard input - session has exited');
return;
}
let inputText = '';
// Handle special keys
switch (e.key) {
case 'Enter':
if (e.ctrlKey) {
// Ctrl+Enter - send to tty-fwd for proper handling
inputText = 'ctrl_enter';
}
else if (e.shiftKey) {
// Shift+Enter - send to tty-fwd for proper handling
inputText = 'shift_enter';
}
else {
// Regular Enter
inputText = 'enter';
}
break;
case 'Escape':
inputText = 'escape';
break;
case 'ArrowUp':
inputText = 'arrow_up';
break;
case 'ArrowDown':
inputText = 'arrow_down';
break;
case 'ArrowLeft':
inputText = 'arrow_left';
break;
case 'ArrowRight':
inputText = 'arrow_right';
break;
case 'Tab':
inputText = '\t';
break;
case 'Backspace':
inputText = '\b';
break;
case 'Delete':
inputText = '\x7f';
break;
case ' ':
inputText = ' ';
break;
default:
// Handle regular printable characters
if (e.key.length === 1) {
inputText = e.key;
}
else {
// Ignore other special keys
return;
}
break;
}
// Handle Ctrl combinations (but not if we already handled Ctrl+Enter above)
if (e.ctrlKey && e.key.length === 1 && e.key !== 'Enter') {
const charCode = e.key.toLowerCase().charCodeAt(0);
if (charCode >= 97 && charCode <= 122) { // a-z
inputText = String.fromCharCode(charCode - 96); // Ctrl+A = \x01, etc.
}
}
// Send the input to the session
try {
const response = await fetch(`/api/sessions/${this.session.id}/input`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ text: inputText })
});
if (!response.ok) {
if (response.status === 400) {
console.log('Session no longer accepting input (likely exited)');
// Update session status to exited if we get 400 error
if (this.session && this.session.status !== 'exited') {
this.session = { ...this.session, status: 'exited' };
this.requestUpdate();
this.stopSessionStatusPolling();
}
}
else {
console.error('Failed to send input to session:', response.status);
}
}
}
catch (error) {
console.error('Error sending input:', error);
}
}
handleBack() {
window.location.search = '';
}
handleSessionExit(e) {
const customEvent = e;
console.log('Session exit event received:', customEvent.detail);
if (this.session && customEvent.detail.sessionId === this.session.id) {
// Update session status to exited
this.session = { ...this.session, status: 'exited' };
this.requestUpdate();
// Stop polling immediately
this.stopSessionStatusPolling();
// Switch to snapshot mode
requestAnimationFrame(() => {
this.createInteractiveTerminal();
});
}
}
// Mobile input methods
handleMobileInputToggle() {
this.showMobileInput = !this.showMobileInput;
if (this.showMobileInput) {
// Focus the textarea after a short delay to ensure it's rendered
requestAnimationFrame(() => {
const textarea = this.querySelector('#mobile-input-textarea');
if (textarea) {
textarea.focus();
this.adjustTextareaForKeyboard();
}
});
}
else {
// Clean up viewport listener when closing overlay
const textarea = this.querySelector('#mobile-input-textarea');
if (textarea && textarea._viewportCleanup) {
textarea._viewportCleanup();
}
}
}
adjustTextareaForKeyboard() {
// Adjust the layout when virtual keyboard appears
const textarea = this.querySelector('#mobile-input-textarea');
const controls = this.querySelector('#mobile-controls');
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');
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;
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;
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._viewportCleanup = cleanup;
}
// Initial adjustment
requestAnimationFrame(adjustLayout);
}
handleMobileInputChange(e) {
const textarea = e.target;
this.mobileInputText = textarea.value;
}
async handleMobileInputSendOnly() {
// Get the current value from the textarea directly
const textarea = this.querySelector('#mobile-input-textarea');
const textToSend = textarea?.value?.trim() || this.mobileInputText.trim();
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
}
}
async handleMobileInputSend() {
// Get the current value from the textarea directly
const textarea = this.querySelector('#mobile-input-textarea');
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
}
}
async handleSpecialKey(key) {
await this.sendInputText(key);
}
async sendInputText(text) {
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);
}
}
adjustTerminalForMobileButtons() {
// Disabled for now to avoid viewport issues
// The mobile buttons will overlay the terminal
}
startLoading() {
this.loading = true;
this.loadingFrame = 0;
this.loadingInterval = window.setInterval(() => {
this.loadingFrame = (this.loadingFrame + 1) % 4;
this.requestUpdate();
}, 200); // Update every 200ms for smooth animation
}
stopLoading() {
this.loading = false;
if (this.loadingInterval) {
clearInterval(this.loadingInterval);
this.loadingInterval = null;
}
}
getLoadingText() {
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
return frames[this.loadingFrame % frames.length];
}
startSessionStatusPolling() {
if (this.sessionStatusInterval) {
clearInterval(this.sessionStatusInterval);
}
// Only poll for running sessions - exited sessions don't need polling
if (this.session?.status !== 'exited') {
this.sessionStatusInterval = window.setInterval(() => {
this.checkSessionStatus();
}, 2000);
}
}
stopSessionStatusPolling() {
if (this.sessionStatusInterval) {
clearInterval(this.sessionStatusInterval);
this.sessionStatusInterval = null;
}
}
async checkSessionStatus() {
if (!this.session)
return;
try {
const response = await fetch('/api/sessions');
if (!response.ok)
return;
const sessions = await response.json();
const currentSession = sessions.find((s) => s.id === this.session.id);
if (currentSession && currentSession.status !== this.session.status) {
// Store old status before updating
const oldStatus = this.session.status;
// Session status changed
this.session = { ...this.session, status: currentSession.status };
this.requestUpdate();
// Session status polling is now only for detecting new sessions
// Exit events are handled via SSE stream directly
}
}
catch (error) {
console.error('Error checking session status:', error);
}
}
render() {
if (!this.session) {
return html `
<div class="p-4 text-vs-muted">
No session selected
</div>
`;
}
return html `
<style>
session-view *, session-view *:focus, session-view *:focus-visible {
outline: none !important;
box-shadow: none !important;
}
</style>
<div class="flex flex-col bg-vs-bg font-mono" style="height: 100vh; outline: none !important; box-shadow: none !important;">
<!-- Compact Header -->
<div class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm">
<div class="flex items-center gap-3">
<button
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
</button>
<div class="text-vs-text">
<div class="text-vs-accent">${this.session.command}</div>
<div class="text-vs-muted text-xs">${this.session.workingDir}</div>
</div>
</div>
<div class="flex items-center gap-3 text-xs">
<span class="${this.session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'}">
${this.session.status.toUpperCase()}
</span>
</div>
</div>
<!-- Terminal Container -->
<div class="flex-1 bg-black overflow-x-auto overflow-y-hidden min-h-0 relative" id="terminal-container">
<div id="interactive-terminal" class="w-full h-full"></div>
${this.loading ? html `
<!-- Loading overlay -->
<div class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center">
<div class="text-vs-text font-mono text-center">
<div class="text-2xl mb-2">${this.getLoadingText()}</div>
<div class="text-sm text-vs-muted">Connecting to session...</div>
</div>
</div>
` : ''}
</div>
<!-- Mobile Input Controls -->
${this.isMobile && !this.showMobileInput ? html `
<div class="flex-shrink-0 p-4 bg-vs-bg">
<!-- 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>
` : ''}
<!-- Full-Screen Input Overlay (only when opened) -->
${this.isMobile && 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) => {
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);">
<!-- Send Buttons Row -->
<div class="flex gap-2 mb-3">
<button
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()}
>
SEND
</button>
<button
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()}
>
SEND + ENTER
</button>
</div>
<div class="text-vs-muted text-xs text-center">
SEND: text only • SEND + ENTER: text with enter key
</div>
</div>
</div>
` : ''}
</div>
`;
}
};
__decorate([
property({ type: Object })
], SessionView.prototype, "session", void 0);
__decorate([
state()
], SessionView.prototype, "connected", void 0);
__decorate([
state()
], SessionView.prototype, "renderer", void 0);
__decorate([
state()
], SessionView.prototype, "sessionStatusInterval", void 0);
__decorate([
state()
], SessionView.prototype, "showMobileInput", void 0);
__decorate([
state()
], SessionView.prototype, "mobileInputText", void 0);
__decorate([
state()
], SessionView.prototype, "isMobile", void 0);
__decorate([
state()
], SessionView.prototype, "touchStartX", void 0);
__decorate([
state()
], SessionView.prototype, "touchStartY", void 0);
__decorate([
state()
], SessionView.prototype, "loading", void 0);
__decorate([
state()
], SessionView.prototype, "loadingFrame", void 0);
SessionView = __decorate([
customElement('session-view')
], SessionView);
export { SessionView };
//# sourceMappingURL=session-view.js.map