import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import './session-create-form.js'; export interface Session { id: string; command: string; workingDir: string; status: 'running' | 'exited'; exitCode?: number; startedAt: string; lastModified: string; pid?: number; } @customElement('session-list') export class SessionList extends LitElement { // Disable shadow DOM to use Tailwind createRenderRoot() { return this; } @property({ type: Array }) sessions: Session[] = []; @property({ type: Boolean }) loading = false; @state() private killingSessionIds = new Set(); @state() private loadedSnapshots = new Map(); @state() private loadingSnapshots = new Set(); @state() private hideExited = true; @state() private showCreateModal = false; @state() private cleaningExited = false; @state() private newSessionIds = new Set(); 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 asciinema player after the element is rendered setTimeout(() => this.createPlayer(sessionId), 10); } 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) { setTimeout(() => { newSessionIdsList.forEach(sessionId => { this.newSessionIds.delete(sessionId); // Remove from new sessions set this.loadSnapshot(sessionId); }); this.requestUpdate(); // Update UI to show the players }, 500); // Wait 500ms for new sessions } } } private createPlayer(sessionId: string) { const playerElement = this.querySelector(`#player-${sessionId}`) as HTMLElement; if (playerElement && (window as any).AsciinemaPlayer) { try { const streamUrl = `/api/sessions/${sessionId}/stream`; (window as any).AsciinemaPlayer.create({driver: "eventsource", url: streamUrl}, playerElement, { autoPlay: true, loop: false, controls: false, fit: 'both', terminalFontSize: '8px', idleTimeLimit: 0.5, preload: true, poster: 'npt:999999' }); } catch (error) { console.error('Error creating asciinema player:', error); } } } private handleSessionClick(session: Session) { this.dispatchEvent(new CustomEvent('session-select', { detail: session })); } private async handleKillSession(e: Event, sessionId: string) { e.stopPropagation(); // Prevent session selection if (!confirm('Are you sure you want to kill this session?')) { return; } this.killingSessionIds.add(sessionId); this.requestUpdate(); try { const response = await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' }); if (response.ok) { this.dispatchEvent(new CustomEvent('session-killed', { detail: { sessionId } })); // Refresh the list after a short delay setTimeout(() => { this.handleRefresh(); }, 1000); } else { const error = await response.json(); this.dispatchEvent(new CustomEvent('error', { detail: `Failed to kill session: ${error.error}` })); } } catch (error) { console.error('Error killing session:', error); this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to kill 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.showCreateModal = false; this.dispatchEvent(new CustomEvent('session-created', { detail: e.detail })); } private handleCreateError(e: CustomEvent) { this.dispatchEvent(new CustomEvent('error', { detail: e.detail })); } 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; } 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'); } 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' })); } finally { this.cleaningExited = false; this.requestUpdate(); } } private get filteredSessions() { return this.hideExited ? this.sessions.filter(session => session.status === 'running') : this.sessions; } render() { const sessionsToShow = this.filteredSessions; return html`
${sessionsToShow.length === 0 ? html`
${this.loading ? 'Loading sessions...' : (this.hideExited && this.sessions.length > 0 ? 'No running sessions' : 'No sessions found')}
` : html`
${sessionsToShow.map(session => html`
this.handleSessionClick(session)} >
${session.command}
${this.loadedSnapshots.has(session.id) ? html`
` : html`
${this.newSessionIds.has(session.id) ? '[~] init_session...' : (this.loadingSnapshots.has(session.id) ? '[~] loading...' : '[~] loading...') }
`}
${session.status} ${this.truncateId(session.id)}
${session.workingDir}
`)}
`} this.showCreateModal = false} @error=${this.handleCreateError} >
`; } }