mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
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:
parent
4757d27d26
commit
ae2c986e33
7 changed files with 444 additions and 514 deletions
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
127
web/src/client/components/session-card.ts
Normal file
127
web/src/client/components/session-card.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue