Implement session-card architecture and improve session management

- Create session-card component for individual session rendering
- Add Renderer active count tracking to monitor instance leaks
- Parse session ID from tty-fwd stdout for reliable session creation
- Add 2-second delay for fresh session connections to prevent race conditions
- Simplify session-list to use session-card components
- Restore original layout with controls and proper padding
- Improve SSE exit event handling with direct session ID usage

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-16 13:50:28 +02:00
parent 4757d27d26
commit ae2c986e33
7 changed files with 444 additions and 514 deletions

View file

@ -6,6 +6,7 @@ import './components/app-header.js';
import './components/session-create-form.js';
import './components/session-list.js';
import './components/session-view.js';
import './components/session-card.js';
import type { Session } from './components/session-list.js';
@ -84,24 +85,24 @@ export class VibeTunnelApp extends LitElement {
}
private startAutoRefresh() {
// Refresh sessions every 3 seconds
// Refresh sessions every 3 seconds, but only when showing session list
setInterval(() => {
this.loadSessions();
if (this.currentView === 'list') {
this.loadSessions();
}
}, 3000);
}
private async handleSessionCreated(e: CustomEvent) {
console.log('Session created event detail:', e.detail);
const sessionId = e.detail.sessionId;
console.log('Extracted sessionId:', sessionId);
if (!sessionId) {
this.showError('Session created but ID not found in response');
return;
}
this.showCreateModal = false;
// Wait for session to appear in the list and then switch to it
await this.waitForSessionAndSwitch(sessionId);
}
@ -109,43 +110,35 @@ export class VibeTunnelApp extends LitElement {
private async waitForSessionAndSwitch(sessionId: string) {
const maxAttempts = 10;
const delay = 500; // 500ms between attempts
console.log(`Waiting for session ${sessionId} to appear...`);
for (let attempt = 0; attempt < maxAttempts; attempt++) {
console.log(`Attempt ${attempt + 1}/${maxAttempts} to find session ${sessionId}`);
await this.loadSessions();
console.log('Current sessions:', this.sessions.map(s => ({ id: s.id, command: s.command })));
// Try to find by exact ID match first
let session = this.sessions.find(s => s.id === sessionId);
// If not found by ID, find the most recently created session
// This works around tty-fwd potentially using different IDs internally
if (!session && this.sessions.length > 0) {
console.log('Session not found by ID, trying to find newest session...');
const sortedSessions = [...this.sessions].sort((a, b) =>
const sortedSessions = [...this.sessions].sort((a, b) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
session = sortedSessions[0];
console.log('Using newest session:', session.id);
}
if (session) {
// Session found, switch to session view
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;
}
// Wait before next attempt
await new Promise(resolve => setTimeout(resolve, delay));
}
// If we get here, session creation might have failed
console.log('Session not found after all attempts');
this.showError('Session created but could not be found. Please refresh.');
@ -167,6 +160,7 @@ export class VibeTunnelApp extends LitElement {
this.updateUrl();
}
private handleSessionKilled(e: CustomEvent) {
console.log('Session killed:', e.detail);
this.loadSessions(); // Refresh the list
@ -196,7 +190,7 @@ export class VibeTunnelApp extends LitElement {
private setupRouting() {
// Handle browser back/forward navigation
window.addEventListener('popstate', this.handlePopState.bind(this));
// Parse initial URL and set state
this.parseUrlAndSetState();
}
@ -209,7 +203,7 @@ export class VibeTunnelApp extends LitElement {
private parseUrlAndSetState() {
const url = new URL(window.location.href);
const sessionId = url.searchParams.get('session');
if (sessionId) {
// Load the specific session
this.loadSessionFromUrl(sessionId);
@ -225,7 +219,7 @@ export class VibeTunnelApp extends LitElement {
if (this.sessions.length === 0) {
await this.loadSessions();
}
// Find the session
const session = this.sessions.find(s => s.id === sessionId);
if (session) {
@ -242,13 +236,13 @@ export class VibeTunnelApp extends LitElement {
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());
}

View file

@ -0,0 +1,127 @@
import { LitElement, html, css, PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Renderer } from '../renderer.js';
export interface Session {
id: string;
command: string;
workingDir: string;
status: 'running' | 'exited';
exitCode?: number;
startedAt: string;
lastModified: string;
pid?: number;
}
@customElement('session-card')
export class SessionCard extends LitElement {
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
@property({ type: Object }) session!: Session;
@state() private renderer: Renderer | null = null;
firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this.createRenderer();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.renderer) {
this.renderer.dispose();
this.renderer = null;
}
}
private createRenderer() {
const playerElement = this.querySelector('#player') as HTMLElement;
if (!playerElement) return;
// Create single renderer for this card
this.renderer = new Renderer(playerElement, 40, 12, 10000, 6, true);
// Connect to appropriate endpoint based on session status
const isStream = this.session.status !== 'exited';
const url = isStream
? `/api/sessions/${this.session.id}/stream`
: `/api/sessions/${this.session.id}/snapshot`;
// 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
setTimeout(() => {
if (this.renderer) {
this.renderer.loadFromUrl(url, isStream);
}
}, delay);
}
private handleCardClick() {
this.dispatchEvent(new CustomEvent('session-select', {
detail: this.session,
bubbles: true,
composed: true
}));
}
private handleKillClick(e: Event) {
e.stopPropagation();
this.dispatchEvent(new CustomEvent('session-kill', {
detail: this.session.id,
bubbles: true,
composed: true
}));
}
render() {
const isRunning = this.session.status === 'running';
const statusColor = isRunning ? 'text-green-400' : 'text-red-400';
return html`
<div class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
@click=${this.handleCardClick}>
<!-- Session Info Header -->
<div class="p-3 border-b border-vs-border">
<div class="flex justify-between items-start mb-2">
<div class="flex-1 min-w-0">
<h3 class="text-vs-foreground font-mono text-sm truncate">
${this.session.command}
</h3>
<p class="text-vs-muted text-xs truncate mt-1">
${this.session.workingDir}
</p>
</div>
<div class="flex items-center gap-2 ml-2">
<span class="${statusColor} text-xs font-medium uppercase tracking-wide">
${this.session.status}
</span>
${isRunning ? html`
<button @click=${this.handleKillClick}
class="bg-red-600 hover:bg-red-700 text-white text-xs px-2 py-1 rounded">
kill
</button>
` : ''}
</div>
</div>
</div>
<!-- Terminal Preview -->
<div class="h-32 bg-vs-bg">
<div id="player" class="w-full h-full"></div>
</div>
<!-- Session Metadata -->
<div class="p-2 text-xs text-vs-muted border-t border-vs-border">
<div class="flex justify-between">
<span>Started: ${new Date(this.session.startedAt).toLocaleString()}</span>
${this.session.pid ? html`<span>PID: ${this.session.pid}</span>` : ''}
</div>
</div>
</div>
`;
}
}

View file

@ -1,7 +1,7 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import './session-create-form.js';
import { Renderer } from '../renderer.js';
import './session-card.js';
export interface Session {
id: string;
@ -27,340 +27,75 @@ export class SessionList extends LitElement {
@property({ type: Boolean }) showCreateModal = false;
@state() private killingSessionIds = new Set<string>();
@state() private loadedSnapshots = new Map<string, string>();
@state() private loadingSnapshots = new Set<string>();
@state() private cleaningExited = false;
@state() private newSessionIds = new Set<string>();
@state() private renderers = new Map<string, Renderer>();
disconnectedCallback() {
super.disconnectedCallback();
// Clean up all renderers
this.renderers.forEach(renderer => {
renderer.dispose();
});
this.renderers.clear();
}
private handleRefresh() {
this.dispatchEvent(new CustomEvent('refresh'));
}
private async loadSnapshot(sessionId: string) {
if (this.loadedSnapshots.has(sessionId) || this.loadingSnapshots.has(sessionId)) {
return;
}
this.loadingSnapshots.add(sessionId);
this.requestUpdate();
try {
// Just mark as loaded and create the player with the endpoint URL
this.loadedSnapshots.set(sessionId, sessionId);
this.requestUpdate();
// Create renderer after the element is rendered
requestAnimationFrame(() => this.createRenderer(sessionId));
} catch (error) {
console.error('Error loading snapshot:', error);
} finally {
this.loadingSnapshots.delete(sessionId);
this.requestUpdate();
}
}
private loadAllSnapshots() {
this.sessions.forEach(session => {
this.loadSnapshot(session.id);
});
}
updated(changedProperties: any) {
super.updated(changedProperties);
if (changedProperties.has('sessions')) {
// Auto-load snapshots for existing sessions immediately, but delay for new ones
const prevSessions = changedProperties.get('sessions') || [];
const newSessionIdsList = this.sessions
.filter(session => !prevSessions.find((prev: Session) => prev.id === session.id))
.map(session => session.id);
// Track new sessions
newSessionIdsList.forEach(id => this.newSessionIds.add(id));
// Load existing sessions immediately
const existingSessions = this.sessions.filter(session =>
!newSessionIdsList.includes(session.id)
);
existingSessions.forEach(session => this.loadSnapshot(session.id));
// Load new sessions after a delay to let them generate some output
if (newSessionIdsList.length > 0) {
// Use a shorter delay for better responsiveness
requestAnimationFrame(() => {
newSessionIdsList.forEach(sessionId => {
this.newSessionIds.delete(sessionId); // Remove from new sessions set
this.loadSnapshot(sessionId);
});
this.requestUpdate(); // Update UI to show the players
});
}
}
// If hideExited changed, recreate players for newly visible sessions
if (changedProperties.has('hideExited')) {
// Use requestAnimationFrame to avoid blocking the checkbox click
requestAnimationFrame(() => {
this.filteredSessions.forEach(session => {
const playerElement = this.querySelector(`#player-${session.id}`);
if (playerElement && this.loadedSnapshots.has(session.id)) {
// Player element exists but might not have a renderer instance
// Check if it's empty and recreate if needed
if (!playerElement.hasChildNodes() || playerElement.children.length === 0) {
this.createRenderer(session.id);
}
}
});
});
}
}
private async createRenderer(sessionId: string) {
const playerElement = this.querySelector(`#player-${sessionId}`) as HTMLElement;
if (!playerElement) {
// Element not ready yet, retry on next frame
requestAnimationFrame(() => this.createRenderer(sessionId));
return;
}
try {
// Clean up existing renderer if it exists
const existingRenderer = this.renderers.get(sessionId);
if (existingRenderer) {
existingRenderer.dispose();
this.renderers.delete(sessionId);
}
// Find the session to check its status
const session = this.sessions.find(s => s.id === sessionId);
if (!session) return;
// Create renderer with smaller dimensions for preview
// Use responsive font sizing, starting with smaller font for previews
const renderer = new Renderer(playerElement, 40, 12, 10000, 6, true); // 40x12 chars, 6px base font, isPreview=true
this.renderers.set(sessionId, renderer);
// Terminal is already configured with disableStdin: true in renderer constructor
// Determine URL and stream type
const isStream = session.status !== 'exited';
const url = isStream
? `/api/sessions/${sessionId}/stream`
: `/api/sessions/${sessionId}/snapshot`;
// Let the renderer handle the URL
await renderer.loadFromUrl(url, isStream);
// Disable pointer events so clicks pass through to the card (after terminal is rendered)
requestAnimationFrame(() => {
renderer.setPointerEventsEnabled(false);
});
} catch (error) {
console.error('Error creating renderer:', error);
}
}
private handleSessionClick(session: Session) {
private handleSessionSelect(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('session-select', {
detail: session
detail: e.detail,
bubbles: true,
composed: true
}));
}
private async handleKillSession(e: Event, sessionId: string) {
e.stopPropagation(); // Prevent session selection
if (!confirm('Are you sure you want to kill this session?')) {
return;
}
private async handleSessionKill(e: CustomEvent) {
const sessionId = e.detail;
if (this.killingSessionIds.has(sessionId)) return;
this.killingSessionIds.add(sessionId);
this.requestUpdate();
try {
const response = await fetch(`/api/sessions/${sessionId}`, {
method: 'DELETE'
const response = await fetch(`/api/sessions/${sessionId}/kill`, {
method: 'POST'
});
if (response.ok) {
// Clean up renderer for this session
const renderer = this.renderers.get(sessionId);
if (renderer) {
renderer.dispose();
this.renderers.delete(sessionId);
}
this.dispatchEvent(new CustomEvent('session-killed', {
detail: { sessionId }
}));
// Refresh the list after a short delay
setTimeout(() => {
this.handleRefresh();
}, 1000);
this.dispatchEvent(new CustomEvent('session-killed', { detail: sessionId }));
} else {
const error = await response.json();
this.dispatchEvent(new CustomEvent('error', {
detail: `Failed to kill session: ${error.error}`
}));
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to kill session' }));
}
} catch (error) {
console.error('Error killing session:', error);
this.dispatchEvent(new CustomEvent('error', {
detail: 'Failed to kill session'
}));
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to kill session' }));
} finally {
this.killingSessionIds.delete(sessionId);
this.requestUpdate();
}
}
private async handleCleanSession(e: Event, sessionId: string) {
e.stopPropagation(); // Prevent session selection
if (!confirm('Are you sure you want to clean up this session?')) {
return;
}
this.killingSessionIds.add(sessionId);
this.requestUpdate();
try {
const response = await fetch(`/api/sessions/${sessionId}/cleanup`, {
method: 'DELETE'
});
if (response.ok) {
// Clean up renderer for this session
const renderer = this.renderers.get(sessionId);
if (renderer) {
renderer.dispose();
this.renderers.delete(sessionId);
}
this.dispatchEvent(new CustomEvent('session-killed', {
detail: { sessionId }
}));
// Refresh the list after a short delay
setTimeout(() => {
this.handleRefresh();
}, 500);
} else {
const error = await response.json();
this.dispatchEvent(new CustomEvent('error', {
detail: `Failed to clean session: ${error.error}`
}));
}
} catch (error) {
console.error('Error cleaning session:', error);
this.dispatchEvent(new CustomEvent('error', {
detail: 'Failed to clean session'
}));
} finally {
this.killingSessionIds.delete(sessionId);
this.requestUpdate();
}
}
private formatTime(timestamp: string): string {
try {
const date = new Date(timestamp);
return date.toLocaleTimeString();
} catch {
return 'Unknown';
}
}
private truncateId(id: string): string {
return id.length > 8 ? `${id.substring(0, 8)}...` : id;
}
private handleSessionCreated(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('session-created', {
detail: e.detail
}));
}
private handleCreateError(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('error', {
detail: e.detail
}));
}
private handleCreateModalClose() {
this.dispatchEvent(new CustomEvent('create-modal-close'));
}
private async handleCleanExited() {
const exitedSessions = this.sessions.filter(session => session.status === 'exited');
if (exitedSessions.length === 0) {
this.dispatchEvent(new CustomEvent('error', {
detail: 'No exited sessions to clean'
}));
return;
}
if (!confirm(`Are you sure you want to delete ${exitedSessions.length} exited session${exitedSessions.length > 1 ? 's' : ''}?`)) {
return;
}
private async handleCleanupExited() {
if (this.cleaningExited) return;
this.cleaningExited = true;
this.requestUpdate();
try {
// Use the bulk cleanup API endpoint
const response = await fetch('/api/cleanup-exited', {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to cleanup exited sessions');
if (response.ok) {
this.dispatchEvent(new CustomEvent('refresh'));
} else {
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' }));
}
this.dispatchEvent(new CustomEvent('error', {
detail: `Successfully cleaned ${exitedSessions.length} exited session${exitedSessions.length > 1 ? 's' : ''}`
}));
// Refresh the list after cleanup
setTimeout(() => {
this.handleRefresh();
}, 500);
} catch (error) {
console.error('Error cleaning exited sessions:', error);
this.dispatchEvent(new CustomEvent('error', {
detail: 'Failed to clean exited sessions'
}));
console.error('Error cleaning up exited sessions:', error);
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' }));
} finally {
this.cleaningExited = false;
this.requestUpdate();
}
}
private handleHideExitedChange(e: Event) {
const checked = (e.target as HTMLInputElement).checked;
this.dispatchEvent(new CustomEvent('hide-exited-change', {
detail: checked
}));
}
private get filteredSessions() {
return this.hideExited
? this.sessions.filter(session => session.status === 'running')
: this.sessions;
}
render() {
const sessionsToShow = this.filteredSessions;
const filteredSessions = this.hideExited
? this.sessions.filter(session => session.status !== 'exited')
: this.sessions;
return html`
<div class="font-mono text-sm p-4">
@ -369,7 +104,7 @@ export class SessionList extends LitElement {
${!this.hideExited ? html`
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none rounded transition-colors disabled:opacity-50"
@click=${this.handleCleanExited}
@click=${this.handleCleanupExited}
?disabled=${this.cleaningExited || this.sessions.filter(s => s.status === 'exited').length === 0}
>
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
@ -382,7 +117,7 @@ export class SessionList extends LitElement {
type="checkbox"
class="sr-only"
.checked=${this.hideExited}
@change=${this.handleHideExitedChange}
@change=${(e: Event) => this.dispatchEvent(new CustomEvent('hide-exited-change', { detail: (e.target as HTMLInputElement).checked }))}
>
<div class="w-4 h-4 border border-vs-border rounded bg-vs-bg-secondary flex items-center justify-center transition-all ${
this.hideExited ? 'bg-vs-user border-vs-user' : 'hover:border-vs-accent'
@ -397,69 +132,27 @@ export class SessionList extends LitElement {
hide exited
</label>
</div>
${sessionsToShow.length === 0 ? html`
${filteredSessions.length === 0 ? html`
<div class="text-vs-muted text-center py-8">
${this.loading ? 'Loading sessions...' : (this.hideExited && this.sessions.length > 0 ? 'No running sessions' : 'No sessions found')}
</div>
` : html`
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${sessionsToShow.map(session => html`
<div
class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
@click=${() => this.handleSessionClick(session)}
>
<!-- Compact Header -->
<div class="flex justify-between items-center px-3 py-2 border-b border-vs-border">
<div class="text-vs-text text-xs font-mono truncate pr-2 flex-1">${session.command}</div>
${session.status === 'running' || !this.hideExited ? html`
<button
class="bg-vs-warning text-vs-bg hover:bg-vs-highlight font-mono px-2 py-0.5 border-none text-xs disabled:opacity-50 flex-shrink-0 rounded"
@click=${(e: Event) => session.status === 'running' ? this.handleKillSession(e, session.id) : this.handleCleanSession(e, session.id)}
?disabled=${this.killingSessionIds.has(session.id)}
>
${this.killingSessionIds.has(session.id)
? (session.status === 'running' ? '[~] killing...' : '[~] cleaning...')
: (session.status === 'running' ? 'kill' : 'clean')
}
</button>
` : ''}
</div>
<!-- XTerm renderer (main content) -->
<div class="session-preview bg-black flex items-center justify-center overflow-hidden" style="aspect-ratio: 640/480;">
${this.loadedSnapshots.has(session.id) ? html`
<div id="player-${session.id}" class="w-full h-full overflow-hidden"></div>
` : html`
<div class="text-vs-muted text-xs">
${this.newSessionIds.has(session.id)
? '[~] init_session...'
: (this.loadingSnapshots.has(session.id) ? '[~] loading...' : '[~] loading...')
}
</div>
`}
</div>
<!-- Compact Footer -->
<div class="px-3 py-2 text-vs-muted text-xs border-t border-vs-border">
<div class="flex justify-between items-center">
<span class="${session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'} text-xs">
${session.status}
</span>
<span class="truncate">${this.truncateId(session.id)}</span>
</div>
<div class="truncate text-xs opacity-75" title="${session.workingDir}">${session.workingDir}</div>
</div>
</div>
${filteredSessions.map(session => html`
<session-card
.session=${session}
@session-select=${this.handleSessionSelect}
@session-kill=${this.handleSessionKill}>
</session-card>
`)}
</div>
`}
<session-create-form
.visible=${this.showCreateModal}
@session-created=${this.handleSessionCreated}
@cancel=${this.handleCreateModalClose}
@error=${this.handleCreateError}
@session-created=${(e: CustomEvent) => this.dispatchEvent(new CustomEvent('session-created', { detail: e.detail }))}
@cancel=${() => this.dispatchEvent(new CustomEvent('create-modal-close'))}
@error=${(e: CustomEvent) => this.dispatchEvent(new CustomEvent('error', { detail: e.detail }))}
></session-create-form>
</div>
`;

View file

@ -1,4 +1,4 @@
import { LitElement, html } from 'lit';
import { LitElement, PropertyValues, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from './session-list.js';
import { Renderer } from '../renderer.js';
@ -22,16 +22,16 @@ export class SessionView extends LitElement {
private keyboardHandler = (e: KeyboardEvent) => {
if (!this.session) return;
e.preventDefault();
e.stopPropagation();
this.handleKeyboardInput(e);
};
private touchStartHandler = (e: TouchEvent) => {
if (!this.isMobile) return;
const touch = e.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
@ -39,19 +39,19 @@ export class SessionView extends LitElement {
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();
@ -61,11 +61,11 @@ export class SessionView extends LitElement {
connectedCallback() {
super.connectedCallback();
this.connected = true;
// 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);
@ -74,7 +74,7 @@ export class SessionView extends LitElement {
document.addEventListener('touchstart', this.touchStartHandler, { passive: true });
document.addEventListener('touchend', this.touchEndHandler, { passive: true });
}
// Start polling session status
this.startSessionStatusPolling();
}
@ -82,7 +82,7 @@ export class SessionView extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
this.connected = false;
// Remove global keyboard event listener
if (!this.isMobile) {
document.removeEventListener('keydown', this.keyboardHandler);
@ -91,10 +91,10 @@ export class SessionView extends LitElement {
document.removeEventListener('touchstart', this.touchStartHandler);
document.removeEventListener('touchend', this.touchEndHandler);
}
// Stop polling session status
this.stopSessionStatusPolling();
// Cleanup renderer if it exists
if (this.renderer) {
this.renderer.dispose();
@ -102,17 +102,14 @@ export class SessionView extends LitElement {
}
}
firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this.createInteractiveTerminal();
}
updated(changedProperties: any) {
super.updated(changedProperties);
if (changedProperties.has('session') && this.session) {
this.createInteractiveTerminal();
// Adjust terminal spacing after creating terminal
requestAnimationFrame(() => {
this.adjustTerminalForMobileButtons();
});
}
// Adjust terminal height for mobile buttons after render
if (changedProperties.has('showMobileInput') || changedProperties.has('isMobile')) {
requestAnimationFrame(() => {
@ -123,37 +120,38 @@ export class SessionView extends LitElement {
private createInteractiveTerminal() {
if (!this.session) return;
const terminalElement = this.querySelector('#interactive-terminal') as HTMLElement;
if (!terminalElement) return;
try {
// Clean up existing renderer
if (this.renderer) {
this.renderer.dispose();
this.renderer = null;
}
// Create renderer once and connect to current session
this.renderer = new Renderer(terminalElement);
// Create new renderer using default parameters (EXACTLY like the test)
this.renderer = new Renderer(terminalElement);
if (this.session.status === 'exited') {
// For ended sessions, load snapshot (EXACTLY like the test)
this.renderer.loadCastFile(`/api/sessions/${this.session.id}/snapshot`);
} else {
// For running sessions, connect to live stream
// 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
setTimeout(() => {
if (this.renderer && this.session) {
this.renderer.connectToStream(this.session.id);
}
} catch (error) {
console.error('Error creating interactive terminal:', error);
}
}, delay);
// Listen for session exit events
terminalElement.addEventListener('session-exit', this.handleSessionExit.bind(this) as EventListener);
}
private async handleKeyboardInput(e: KeyboardEvent) {
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':
@ -225,7 +223,17 @@ export class SessionView extends LitElement {
});
if (!response.ok) {
console.error('Failed to send input to session');
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 as string) !== '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);
@ -236,6 +244,25 @@ export class SessionView extends LitElement {
this.dispatchEvent(new CustomEvent('back'));
}
private handleSessionExit(e: Event) {
const customEvent = e as CustomEvent;
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
private handleMobileInputToggle() {
this.showMobileInput = !this.showMobileInput;
@ -267,19 +294,19 @@ export class SessionView extends LitElement {
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;
@ -288,7 +315,7 @@ export class SessionView extends LitElement {
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);
@ -299,7 +326,7 @@ export class SessionView extends LitElement {
// 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) {
@ -338,22 +365,22 @@ export class SessionView extends LitElement {
// 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 {
// 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) {
@ -366,22 +393,22 @@ export class SessionView extends LitElement {
// 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) {
@ -423,11 +450,13 @@ export class SessionView extends LitElement {
if (this.sessionStatusInterval) {
clearInterval(this.sessionStatusInterval);
}
// Poll every 2 seconds
this.sessionStatusInterval = window.setInterval(() => {
this.checkSessionStatus();
}, 2000);
// Only poll for running sessions - exited sessions don't need polling
if (this.session?.status !== 'exited') {
this.sessionStatusInterval = window.setInterval(() => {
this.checkSessionStatus();
}, 2000);
}
}
private stopSessionStatusPolling() {
@ -443,25 +472,20 @@ export class SessionView extends LitElement {
try {
const response = await fetch('/api/sessions');
if (!response.ok) return;
const sessions = await response.json();
const currentSession = sessions.find((s: Session) => 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();
// If session ended, switch from stream to snapshot to prevent restarts
if (currentSession.status === 'exited' && this.session.status === 'running') {
console.log('Session ended, switching to snapshot view');
try {
// Recreate with snapshot
this.createInteractiveTerminal();
} catch (error) {
console.error('Error switching to snapshot:', error);
}
}
// 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);
@ -541,7 +565,7 @@ export class SessionView extends LitElement {
</button>
</div>
<!-- Second row: Special keys -->
<div class="flex gap-2">
<button
@ -612,7 +636,7 @@ export class SessionView extends LitElement {
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 -->
@ -632,7 +656,7 @@ export class SessionView extends LitElement {
SEND + ENTER
</button>
</div>
<div class="text-vs-muted text-xs text-center">
SEND: text only SEND + ENTER: text with enter key
</div>

View file

@ -21,6 +21,8 @@ interface CastEvent {
}
export class Renderer {
private static activeCount: number = 0;
private container: HTMLElement;
private terminal: Terminal;
private fitAddon: FitAddon;
@ -29,6 +31,8 @@ export class Renderer {
private isPreview: boolean;
constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14, isPreview: boolean = false) {
Renderer.activeCount++;
console.log(`Renderer constructor called (active: ${Renderer.activeCount})`);
this.container = container;
this.isPreview = isPreview;
@ -194,11 +198,13 @@ export class Renderer {
// Stream support - connect to SSE endpoint
connectToStream(sessionId: string): EventSource {
console.log('connectToStream called for session:', sessionId);
return this.connectToUrl(`/api/sessions/${sessionId}/stream`);
}
// Connect to any SSE URL
connectToUrl(url: string): EventSource {
console.log('Creating new EventSource connection to:', url);
const eventSource = new EventSource(url);
// Don't clear terminal for live streams - just append new content
@ -212,17 +218,34 @@ export class Renderer {
console.log('Received header:', data);
this.resize(data.width, data.height);
} else if (Array.isArray(data) && data.length >= 3) {
// Event
// Check if this is an exit event
if (data[0] === 'exit') {
const exitCode = data[1];
const sessionId = data[2];
console.log(`Session ${sessionId} exited with code ${exitCode}`);
// Close the SSE connection immediately
if (this.eventSource) {
console.log('Closing SSE connection due to session exit');
this.eventSource.close();
this.eventSource = null;
}
// Dispatch custom event that session-view can listen to
const exitEvent = new CustomEvent('session-exit', {
detail: { sessionId, exitCode }
});
this.container.dispatchEvent(exitEvent);
return;
}
// Regular cast event
const castEvent: CastEvent = {
timestamp: data[0],
type: data[1],
data: data[2]
};
console.log('Received event:', castEvent.type, 'data length:', castEvent.data.length);
// Log first 100 chars of data to see escape sequences
if (castEvent.data.length > 0) {
console.log('Event data preview:', JSON.stringify(castEvent.data.substring(0, 100)));
}
// Process event without verbose logging
this.processEvent(castEvent);
}
} catch (e) {
@ -232,6 +255,13 @@ export class Renderer {
eventSource.onerror = (error) => {
console.error('Stream error:', error);
// Close the connection to prevent automatic reconnection attempts
if (eventSource.readyState === EventSource.CLOSED) {
console.log('Stream closed, cleaning up...');
if (this.eventSource === eventSource) {
this.eventSource = null;
}
}
};
return eventSource;
@ -243,6 +273,7 @@ export class Renderer {
async loadFromUrl(url: string, isStream: boolean): Promise<void> {
// Clean up existing connection
if (this.eventSource) {
console.log('Explicitly closing existing EventSource connection');
this.eventSource.close();
this.eventSource = null;
}
@ -273,10 +304,13 @@ export class Renderer {
dispose(): void {
if (this.eventSource) {
console.log('Explicitly closing EventSource connection in dispose()');
this.eventSource.close();
this.eventSource = null;
}
this.terminal.dispose();
Renderer.activeCount--;
console.log(`Renderer disposed (active: ${Renderer.activeCount})`);
}
// Method to fit terminal to container (useful for responsive layouts)

View file

@ -11,6 +11,8 @@ interface ITerminalDimensions {
}
const MINIMUM_ROWS = 1;
const MIN_FONT_SIZE = 6;
const MAX_FONT_SIZE = 16;
export class ScaleFitAddon implements ITerminalAddon {
private _terminal: Terminal | undefined;
@ -38,40 +40,48 @@ export class ScaleFitAddon implements ITerminalAddon {
return undefined;
}
// Get container dimensions
const parentElement = this._terminal.element.parentElement;
const parentStyle = window.getComputedStyle(parentElement);
const parentWidth = parseInt(parentStyle.getPropertyValue('width'));
const parentHeight = parseInt(parentStyle.getPropertyValue('height'));
// Get the renderer container (parent of parent - the one with 10px padding)
const terminalWrapper = this._terminal.element.parentElement;
const rendererContainer = terminalWrapper.parentElement;
// Get terminal element padding
const elementStyle = window.getComputedStyle(this._terminal.element);
const padding = {
top: parseInt(elementStyle.getPropertyValue('padding-top')),
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
left: parseInt(elementStyle.getPropertyValue('padding-left')),
right: parseInt(elementStyle.getPropertyValue('padding-right'))
if (!rendererContainer) return undefined;
// Get container dimensions and exact padding
const containerStyle = window.getComputedStyle(rendererContainer);
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
const containerHeight = parseInt(containerStyle.getPropertyValue('height'));
const containerPadding = {
top: parseInt(containerStyle.getPropertyValue('padding-top')),
bottom: parseInt(containerStyle.getPropertyValue('padding-bottom')),
left: parseInt(containerStyle.getPropertyValue('padding-left')),
right: parseInt(containerStyle.getPropertyValue('padding-right'))
};
// Calculate available space
const availableWidth = parentWidth - padding.left - padding.right - 20; // Extra margin
const availableHeight = parentHeight - padding.top - padding.bottom - 20;
// Calculate exact available space using known padding
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
const availableHeight = containerHeight - containerPadding.top - containerPadding.bottom;
// Current terminal dimensions
const currentCols = this._terminal.cols;
// Calculate optimal font size to fit current cols in available width
// Character width is approximately 0.6 * fontSize for monospace fonts
const charWidthRatio = 0.6;
const optimalFontSize = Math.max(6, availableWidth / (currentCols * charWidthRatio));
const calculatedFontSize = availableWidth / (currentCols * charWidthRatio);
const optimalFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
// Apply the calculated font size (outside of proposeDimensions to avoid recursion)
setTimeout(() => this.applyFontSize(optimalFontSize), 0);
// Calculate line height (typically 1.2 * fontSize)
const lineHeight = optimalFontSize * 1.2;
// Calculate how many rows fit with this font size
requestAnimationFrame(() => this.applyFontSize(optimalFontSize));
// Get the actual line height from the rendered XTerm element
const xtermElement = this._terminal.element;
const currentStyle = window.getComputedStyle(xtermElement);
const actualLineHeight = parseFloat(currentStyle.lineHeight);
// If we can't get the line height, fall back to configuration
const lineHeight = actualLineHeight || (optimalFontSize * (this._terminal.options.lineHeight || 1.2));
// Calculate how many rows fit with this line height
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
return {
@ -88,13 +98,13 @@ export class ScaleFitAddon implements ITerminalAddon {
if (Math.abs(fontSize - currentFontSize) < 0.1) return;
const terminalElement = this._terminal.element;
// Update terminal's font size
this._terminal.options.fontSize = fontSize;
// Apply CSS font size to the element
terminalElement.style.fontSize = `${fontSize}px`;
// Force a refresh to apply the new font size
requestAnimationFrame(() => {
if (this._terminal) {
@ -114,14 +124,15 @@ export class ScaleFitAddon implements ITerminalAddon {
const parentElement = this._terminal.element.parentElement;
const parentStyle = window.getComputedStyle(parentElement);
const parentWidth = parseInt(parentStyle.getPropertyValue('width'));
const elementStyle = window.getComputedStyle(this._terminal.element);
const paddingHor = parseInt(elementStyle.getPropertyValue('padding-left')) +
const paddingHor = parseInt(elementStyle.getPropertyValue('padding-left')) +
parseInt(elementStyle.getPropertyValue('padding-right'));
const availableWidth = parentWidth - paddingHor;
const charWidthRatio = 0.6;
return availableWidth / (this._terminal.cols * charWidthRatio);
const calculatedFontSize = availableWidth / (this._terminal.cols * charWidthRatio);
return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, calculatedFontSize));
}
}

View file

@ -206,21 +206,64 @@ app.post('/api/sessions', async (req, res) => {
stdio: 'pipe'
});
// Log output for debugging
// Capture session ID from stdout
let sessionId = '';
child.stdout.on('data', (data) => {
console.log(`Session ${sessionName} stdout:`, data.toString());
const output = data.toString().trim();
if (output && !sessionId) {
// First line of output should be the session ID
sessionId = output;
console.log(`Session created with ID: ${sessionId}`);
}
});
child.stderr.on('data', (data) => {
console.log(`Session ${sessionName} stderr:`, data.toString());
// Only log stderr if it contains actual errors
const output = data.toString();
if (output.includes('error') || output.includes('Error')) {
console.error(`Session ${sessionName} stderr:`, output);
}
});
child.on('close', (code) => {
console.log(`Session ${sessionName} exited with code: ${code}`);
child.on('close', async (code) => {
console.log(`Session ${sessionId || sessionName} exited with code: ${code}`);
// Send exit event to all clients watching this session
const streamInfo = activeStreams.get(sessionId);
if (streamInfo) {
console.log(`Sending exit event to stream ${sessionId}`);
const exitEvent = JSON.stringify(['exit', code, sessionId]);
const eventData = `data: ${exitEvent}\n\n`;
streamInfo.clients.forEach(client => {
try {
client.write(eventData);
} catch (error) {
console.error('Error sending exit event to client:', error);
}
});
}
});
// Respond immediately - don't wait for completion
res.json({ sessionId: sessionName });
// Wait for session ID from tty-fwd or timeout after 3 seconds
const waitForSessionId = new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Failed to get session ID from tty-fwd within 3 seconds'));
}, 3000);
const checkSessionId = () => {
if (sessionId) {
clearTimeout(timeout);
resolve(sessionId);
} else {
setTimeout(checkSessionId, 100);
}
};
checkSessionId();
});
const finalSessionId = await waitForSessionId;
res.json({ sessionId: finalSessionId });
} catch (error) {
console.error('Error creating session:', error);
@ -313,10 +356,10 @@ app.post('/api/cleanup-exited', async (req, res) => {
// === TERMINAL I/O ===
// Track active streams per session to avoid multiple tail processes
const activeStreams = new Map<string, {
clients: Set<any>,
tailProcess: any,
lastPosition: number
const activeStreams = new Map<string, {
clients: Set<any>,
tailProcess: any,
lastPosition: number
}>();
// Live streaming cast file for XTerm renderer
@ -328,7 +371,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
return res.status(404).json({ error: 'Session not found' });
}
console.log(`New SSE client connected to session ${sessionId}`);
console.log(`New SSE client connected to session ${sessionId} from ${req.get('User-Agent')?.substring(0, 50) || 'unknown'}`);
res.writeHead(200, {
'Content-Type': 'text/event-stream',
@ -380,22 +423,22 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
// Get or create shared stream for this session
let streamInfo = activeStreams.get(sessionId);
if (!streamInfo) {
console.log(`Creating new shared tail process for session ${sessionId}`);
// Create new tail process for this session
const tailProcess = spawn('tail', ['-f', streamOutPath]);
let buffer = '';
streamInfo = {
clients: new Set(),
tailProcess,
lastPosition: 0
};
activeStreams.set(sessionId, streamInfo);
// Handle tail output - broadcast to all clients
tailProcess.stdout.on('data', (chunk) => {
buffer += chunk.toString();
@ -421,7 +464,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
const castEvent = [currentTime - startTime, "o", line];
eventData = `data: ${JSON.stringify(castEvent)}\n\n`;
}
if (eventData && streamInfo) {
// Broadcast to all connected clients
streamInfo.clients.forEach(client => {
@ -431,6 +474,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
console.error('Error writing to client:', error);
if (streamInfo) {
streamInfo.clients.delete(client);
console.log(`Removed failed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`);
}
}
});
@ -453,7 +497,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
tailProcess.on('exit', (code) => {
console.log(`Shared tail process exited for session ${sessionId} with code ${code}`);
// Cleanup all clients
// Cleanup all clients
const currentStreamInfo = activeStreams.get(sessionId);
if (currentStreamInfo) {
currentStreamInfo.clients.forEach(client => {
@ -473,7 +517,7 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
if (streamInfo && streamInfo.clients.has(res)) {
streamInfo.clients.delete(res);
console.log(`Removed client from session ${sessionId}, remaining clients: ${streamInfo.clients.size}`);
// If no more clients, cleanup the tail process
if (streamInfo.clients.size === 0) {
console.log(`No more clients for session ${sessionId}, cleaning up tail process`);
@ -487,6 +531,9 @@ app.get('/api/sessions/:sessionId/stream', async (req, res) => {
req.on('close', cleanup);
req.on('aborted', cleanup);
req.on('error', cleanup);
res.on('close', cleanup);
res.on('finish', cleanup);
});
// Get session snapshot (cast with adjusted timestamps for immediate playback)
@ -573,7 +620,7 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
// Validate session exists and is running
const output = await executeTtyFwd(['--control-path', TTY_FWD_CONTROL_DIR, '--list-sessions']);
const sessions: TtyFwdListResponse = JSON.parse(output || '{}');
if (!sessions[sessionId]) {
console.error(`Session ${sessionId} not found in active sessions`);
return res.status(404).json({ error: 'Session not found' });
@ -610,21 +657,21 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
const isSpecialKey = specialKeys.includes(text);
const startTime = Date.now();
if (isSpecialKey) {
await executeTtyFwd([
'--control-path', TTY_FWD_CONTROL_DIR,
'--session', sessionId,
'--send-key', text
]);
console.log(`Successfully sent key: ${text} (${Date.now() - startTime}ms)`);
// Key sent successfully (removed verbose logging)
} else {
await executeTtyFwd([
'--control-path', TTY_FWD_CONTROL_DIR,
'--session', sessionId,
'--send-text', text
]);
console.log(`Successfully sent text: ${text} (${Date.now() - startTime}ms)`);
// Text sent successfully (removed verbose logging)
}
res.json({ success: true });
@ -641,7 +688,7 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
// Serve test cast file
app.get('/api/test-cast', (req, res) => {
const testCastPath = path.join(__dirname, '..', 'public', 'stream-out');
try {
if (fs.existsSync(testCastPath)) {
res.setHeader('Content-Type', 'text/plain');