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,16 +85,16 @@ export class VibeTunnelApp extends LitElement {
}
private startAutoRefresh() {
// Refresh sessions every 3 seconds
// Refresh sessions every 3 seconds, but only when showing session list
setInterval(() => {
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');
@ -110,35 +111,27 @@ export class VibeTunnelApp extends LitElement {
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) =>
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;
}
@ -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

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';
@ -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(() => {
@ -127,31 +124,32 @@ export class SessionView extends LitElement {
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 new renderer using default parameters (EXACTLY like the test)
// Create renderer once and connect to current session
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
@ -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;
@ -424,11 +451,13 @@ export class SessionView extends LitElement {
clearInterval(this.sessionStatusInterval);
}
// Poll every 2 seconds
// 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() {
if (this.sessionStatusInterval) {
@ -448,20 +477,15 @@ export class SessionView extends LitElement {
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);

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,24 +40,26 @@ 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;
@ -63,15 +67,21 @@ export class ScaleFitAddon implements ITerminalAddon {
// 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);
requestAnimationFrame(() => this.applyFontSize(optimalFontSize));
// Calculate line height (typically 1.2 * fontSize)
const lineHeight = optimalFontSize * 1.2;
// 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);
// Calculate how many rows fit with this font size
// 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 {
@ -121,7 +131,8 @@ export class ScaleFitAddon implements ITerminalAddon {
const availableWidth = parentWidth - paddingHor;
const charWidthRatio = 0.6;
const calculatedFontSize = availableWidth / (this._terminal.cols * charWidthRatio);
return 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);
@ -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',
@ -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}`);
}
}
});
@ -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)
@ -617,14 +664,14 @@ app.post('/api/sessions/:sessionId/input', async (req, res) => {
'--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 });