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-create-form.js';
|
||||||
import './components/session-list.js';
|
import './components/session-list.js';
|
||||||
import './components/session-view.js';
|
import './components/session-view.js';
|
||||||
|
import './components/session-card.js';
|
||||||
|
|
||||||
import type { Session } from './components/session-list.js';
|
import type { Session } from './components/session-list.js';
|
||||||
|
|
||||||
|
|
@ -84,16 +85,16 @@ export class VibeTunnelApp extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private startAutoRefresh() {
|
private startAutoRefresh() {
|
||||||
// Refresh sessions every 3 seconds
|
// Refresh sessions every 3 seconds, but only when showing session list
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.loadSessions();
|
if (this.currentView === 'list') {
|
||||||
|
this.loadSessions();
|
||||||
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSessionCreated(e: CustomEvent) {
|
private async handleSessionCreated(e: CustomEvent) {
|
||||||
console.log('Session created event detail:', e.detail);
|
|
||||||
const sessionId = e.detail.sessionId;
|
const sessionId = e.detail.sessionId;
|
||||||
console.log('Extracted sessionId:', sessionId);
|
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
this.showError('Session created but ID not found in response');
|
this.showError('Session created but ID not found in response');
|
||||||
|
|
@ -110,35 +111,27 @@ export class VibeTunnelApp extends LitElement {
|
||||||
const maxAttempts = 10;
|
const maxAttempts = 10;
|
||||||
const delay = 500; // 500ms between attempts
|
const delay = 500; // 500ms between attempts
|
||||||
|
|
||||||
console.log(`Waiting for session ${sessionId} to appear...`);
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
console.log(`Attempt ${attempt + 1}/${maxAttempts} to find session ${sessionId}`);
|
|
||||||
await this.loadSessions();
|
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
|
// Try to find by exact ID match first
|
||||||
let session = this.sessions.find(s => s.id === sessionId);
|
let session = this.sessions.find(s => s.id === sessionId);
|
||||||
|
|
||||||
// If not found by ID, find the most recently created session
|
// If not found by ID, find the most recently created session
|
||||||
// This works around tty-fwd potentially using different IDs internally
|
// This works around tty-fwd potentially using different IDs internally
|
||||||
if (!session && this.sessions.length > 0) {
|
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()
|
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||||
);
|
);
|
||||||
session = sortedSessions[0];
|
session = sortedSessions[0];
|
||||||
console.log('Using newest session:', session.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
// Session found, switch to session view
|
// Session found, switch to session view
|
||||||
console.log('Session found, switching to session view');
|
|
||||||
this.selectedSession = session;
|
this.selectedSession = session;
|
||||||
this.currentView = 'session';
|
this.currentView = 'session';
|
||||||
// Update URL to include session ID
|
// Update URL to include session ID
|
||||||
this.updateUrl(session.id);
|
this.updateUrl(session.id);
|
||||||
this.showError('Session created successfully!');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,6 +160,7 @@ export class VibeTunnelApp extends LitElement {
|
||||||
this.updateUrl();
|
this.updateUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private handleSessionKilled(e: CustomEvent) {
|
private handleSessionKilled(e: CustomEvent) {
|
||||||
console.log('Session killed:', e.detail);
|
console.log('Session killed:', e.detail);
|
||||||
this.loadSessions(); // Refresh the list
|
this.loadSessions(); // Refresh the list
|
||||||
|
|
|
||||||
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 { LitElement, html } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import './session-create-form.js';
|
import './session-create-form.js';
|
||||||
import { Renderer } from '../renderer.js';
|
import './session-card.js';
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -27,340 +27,75 @@ export class SessionList extends LitElement {
|
||||||
@property({ type: Boolean }) showCreateModal = false;
|
@property({ type: Boolean }) showCreateModal = false;
|
||||||
|
|
||||||
@state() private killingSessionIds = new Set<string>();
|
@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 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() {
|
private handleRefresh() {
|
||||||
this.dispatchEvent(new CustomEvent('refresh'));
|
this.dispatchEvent(new CustomEvent('refresh'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadSnapshot(sessionId: string) {
|
private handleSessionSelect(e: CustomEvent) {
|
||||||
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) {
|
|
||||||
this.dispatchEvent(new CustomEvent('session-select', {
|
this.dispatchEvent(new CustomEvent('session-select', {
|
||||||
detail: session
|
detail: e.detail,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleKillSession(e: Event, sessionId: string) {
|
private async handleSessionKill(e: CustomEvent) {
|
||||||
e.stopPropagation(); // Prevent session selection
|
const sessionId = e.detail;
|
||||||
|
if (this.killingSessionIds.has(sessionId)) return;
|
||||||
if (!confirm('Are you sure you want to kill this session?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.killingSessionIds.add(sessionId);
|
this.killingSessionIds.add(sessionId);
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/sessions/${sessionId}`, {
|
const response = await fetch(`/api/sessions/${sessionId}/kill`, {
|
||||||
method: 'DELETE'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Clean up renderer for this session
|
this.dispatchEvent(new CustomEvent('session-killed', { detail: sessionId }));
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to kill session' }));
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
|
||||||
detail: `Failed to kill session: ${error.error}`
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error killing session:', error);
|
console.error('Error killing session:', error);
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to kill session' }));
|
||||||
detail: 'Failed to kill session'
|
|
||||||
}));
|
|
||||||
} finally {
|
} finally {
|
||||||
this.killingSessionIds.delete(sessionId);
|
this.killingSessionIds.delete(sessionId);
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCleanSession(e: Event, sessionId: string) {
|
private async handleCleanupExited() {
|
||||||
e.stopPropagation(); // Prevent session selection
|
if (this.cleaningExited) return;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cleaningExited = true;
|
this.cleaningExited = true;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the bulk cleanup API endpoint
|
|
||||||
const response = await fetch('/api/cleanup-exited', {
|
const response = await fetch('/api/cleanup-exited', {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (response.ok) {
|
||||||
throw new Error('Failed to cleanup exited sessions');
|
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) {
|
} catch (error) {
|
||||||
console.error('Error cleaning exited sessions:', error);
|
console.error('Error cleaning up exited sessions:', error);
|
||||||
this.dispatchEvent(new CustomEvent('error', {
|
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' }));
|
||||||
detail: 'Failed to clean exited sessions'
|
|
||||||
}));
|
|
||||||
} finally {
|
} finally {
|
||||||
this.cleaningExited = false;
|
this.cleaningExited = false;
|
||||||
this.requestUpdate();
|
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() {
|
render() {
|
||||||
const sessionsToShow = this.filteredSessions;
|
const filteredSessions = this.hideExited
|
||||||
|
? this.sessions.filter(session => session.status !== 'exited')
|
||||||
|
: this.sessions;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="font-mono text-sm p-4">
|
<div class="font-mono text-sm p-4">
|
||||||
|
|
@ -369,7 +104,7 @@ export class SessionList extends LitElement {
|
||||||
${!this.hideExited ? html`
|
${!this.hideExited ? html`
|
||||||
<button
|
<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"
|
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}
|
?disabled=${this.cleaningExited || this.sessions.filter(s => s.status === 'exited').length === 0}
|
||||||
>
|
>
|
||||||
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
|
${this.cleaningExited ? '[~] CLEANING...' : 'CLEAN EXITED'}
|
||||||
|
|
@ -382,7 +117,7 @@ export class SessionList extends LitElement {
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
.checked=${this.hideExited}
|
.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 ${
|
<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'
|
this.hideExited ? 'bg-vs-user border-vs-user' : 'hover:border-vs-accent'
|
||||||
|
|
@ -397,69 +132,27 @@ export class SessionList extends LitElement {
|
||||||
hide exited
|
hide exited
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
${filteredSessions.length === 0 ? html`
|
||||||
${sessionsToShow.length === 0 ? html`
|
|
||||||
<div class="text-vs-muted text-center py-8">
|
<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')}
|
${this.loading ? 'Loading sessions...' : (this.hideExited && this.sessions.length > 0 ? 'No running sessions' : 'No sessions found')}
|
||||||
</div>
|
</div>
|
||||||
` : html`
|
` : html`
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
${sessionsToShow.map(session => html`
|
${filteredSessions.map(session => html`
|
||||||
<div
|
<session-card
|
||||||
class="bg-vs-bg border border-vs-border rounded shadow cursor-pointer overflow-hidden"
|
.session=${session}
|
||||||
@click=${() => this.handleSessionClick(session)}
|
@session-select=${this.handleSessionSelect}
|
||||||
>
|
@session-kill=${this.handleSessionKill}>
|
||||||
<!-- Compact Header -->
|
</session-card>
|
||||||
<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>
|
|
||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
<session-create-form
|
<session-create-form
|
||||||
.visible=${this.showCreateModal}
|
.visible=${this.showCreateModal}
|
||||||
@session-created=${this.handleSessionCreated}
|
@session-created=${(e: CustomEvent) => this.dispatchEvent(new CustomEvent('session-created', { detail: e.detail }))}
|
||||||
@cancel=${this.handleCreateModalClose}
|
@cancel=${() => this.dispatchEvent(new CustomEvent('create-modal-close'))}
|
||||||
@error=${this.handleCreateError}
|
@error=${(e: CustomEvent) => this.dispatchEvent(new CustomEvent('error', { detail: e.detail }))}
|
||||||
></session-create-form>
|
></session-create-form>
|
||||||
</div>
|
</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 { customElement, property, state } from 'lit/decorators.js';
|
||||||
import type { Session } from './session-list.js';
|
import type { Session } from './session-list.js';
|
||||||
import { Renderer } from '../renderer.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) {
|
updated(changedProperties: any) {
|
||||||
super.updated(changedProperties);
|
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
|
// Adjust terminal height for mobile buttons after render
|
||||||
if (changedProperties.has('showMobileInput') || changedProperties.has('isMobile')) {
|
if (changedProperties.has('showMobileInput') || changedProperties.has('isMobile')) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
@ -127,31 +124,32 @@ export class SessionView extends LitElement {
|
||||||
const terminalElement = this.querySelector('#interactive-terminal') as HTMLElement;
|
const terminalElement = this.querySelector('#interactive-terminal') as HTMLElement;
|
||||||
if (!terminalElement) return;
|
if (!terminalElement) return;
|
||||||
|
|
||||||
try {
|
// Create renderer once and connect to current session
|
||||||
// Clean up existing renderer
|
this.renderer = new Renderer(terminalElement);
|
||||||
if (this.renderer) {
|
|
||||||
this.renderer.dispose();
|
|
||||||
this.renderer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new renderer using default parameters (EXACTLY like the test)
|
// Wait a moment for freshly created sessions before connecting
|
||||||
this.renderer = new Renderer(terminalElement);
|
const sessionAge = Date.now() - new Date(this.session.startedAt).getTime();
|
||||||
|
const delay = sessionAge < 5000 ? 2000 : 0; // 2 second delay if session is less than 5 seconds old
|
||||||
|
|
||||||
if (this.session.status === 'exited') {
|
setTimeout(() => {
|
||||||
// For ended sessions, load snapshot (EXACTLY like the test)
|
if (this.renderer && this.session) {
|
||||||
this.renderer.loadCastFile(`/api/sessions/${this.session.id}/snapshot`);
|
|
||||||
} else {
|
|
||||||
// For running sessions, connect to live stream
|
|
||||||
this.renderer.connectToStream(this.session.id);
|
this.renderer.connectToStream(this.session.id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}, delay);
|
||||||
console.error('Error creating interactive terminal:', error);
|
|
||||||
}
|
// Listen for session exit events
|
||||||
|
terminalElement.addEventListener('session-exit', this.handleSessionExit.bind(this) as EventListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleKeyboardInput(e: KeyboardEvent) {
|
private async handleKeyboardInput(e: KeyboardEvent) {
|
||||||
if (!this.session) return;
|
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 = '';
|
let inputText = '';
|
||||||
|
|
||||||
// Handle special keys
|
// Handle special keys
|
||||||
|
|
@ -225,7 +223,17 @@ export class SessionView extends LitElement {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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) {
|
} catch (error) {
|
||||||
console.error('Error sending input:', error);
|
console.error('Error sending input:', error);
|
||||||
|
|
@ -236,6 +244,25 @@ export class SessionView extends LitElement {
|
||||||
this.dispatchEvent(new CustomEvent('back'));
|
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
|
// Mobile input methods
|
||||||
private handleMobileInputToggle() {
|
private handleMobileInputToggle() {
|
||||||
this.showMobileInput = !this.showMobileInput;
|
this.showMobileInput = !this.showMobileInput;
|
||||||
|
|
@ -424,10 +451,12 @@ export class SessionView extends LitElement {
|
||||||
clearInterval(this.sessionStatusInterval);
|
clearInterval(this.sessionStatusInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll every 2 seconds
|
// Only poll for running sessions - exited sessions don't need polling
|
||||||
this.sessionStatusInterval = window.setInterval(() => {
|
if (this.session?.status !== 'exited') {
|
||||||
this.checkSessionStatus();
|
this.sessionStatusInterval = window.setInterval(() => {
|
||||||
}, 2000);
|
this.checkSessionStatus();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopSessionStatusPolling() {
|
private stopSessionStatusPolling() {
|
||||||
|
|
@ -448,20 +477,15 @@ export class SessionView extends LitElement {
|
||||||
const currentSession = sessions.find((s: Session) => s.id === this.session!.id);
|
const currentSession = sessions.find((s: Session) => s.id === this.session!.id);
|
||||||
|
|
||||||
if (currentSession && currentSession.status !== this.session.status) {
|
if (currentSession && currentSession.status !== this.session.status) {
|
||||||
|
// Store old status before updating
|
||||||
|
const oldStatus = this.session.status;
|
||||||
|
|
||||||
// Session status changed
|
// Session status changed
|
||||||
this.session = { ...this.session, status: currentSession.status };
|
this.session = { ...this.session, status: currentSession.status };
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|
||||||
// If session ended, switch from stream to snapshot to prevent restarts
|
// Session status polling is now only for detecting new sessions
|
||||||
if (currentSession.status === 'exited' && this.session.status === 'running') {
|
// Exit events are handled via SSE stream directly
|
||||||
console.log('Session ended, switching to snapshot view');
|
|
||||||
try {
|
|
||||||
// Recreate with snapshot
|
|
||||||
this.createInteractiveTerminal();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error switching to snapshot:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking session status:', error);
|
console.error('Error checking session status:', error);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ interface CastEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Renderer {
|
export class Renderer {
|
||||||
|
private static activeCount: number = 0;
|
||||||
|
|
||||||
private container: HTMLElement;
|
private container: HTMLElement;
|
||||||
private terminal: Terminal;
|
private terminal: Terminal;
|
||||||
private fitAddon: FitAddon;
|
private fitAddon: FitAddon;
|
||||||
|
|
@ -29,6 +31,8 @@ export class Renderer {
|
||||||
private isPreview: boolean;
|
private isPreview: boolean;
|
||||||
|
|
||||||
constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14, isPreview: boolean = false) {
|
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.container = container;
|
||||||
this.isPreview = isPreview;
|
this.isPreview = isPreview;
|
||||||
|
|
||||||
|
|
@ -194,11 +198,13 @@ export class Renderer {
|
||||||
|
|
||||||
// Stream support - connect to SSE endpoint
|
// Stream support - connect to SSE endpoint
|
||||||
connectToStream(sessionId: string): EventSource {
|
connectToStream(sessionId: string): EventSource {
|
||||||
|
console.log('connectToStream called for session:', sessionId);
|
||||||
return this.connectToUrl(`/api/sessions/${sessionId}/stream`);
|
return this.connectToUrl(`/api/sessions/${sessionId}/stream`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to any SSE URL
|
// Connect to any SSE URL
|
||||||
connectToUrl(url: string): EventSource {
|
connectToUrl(url: string): EventSource {
|
||||||
|
console.log('Creating new EventSource connection to:', url);
|
||||||
const eventSource = new EventSource(url);
|
const eventSource = new EventSource(url);
|
||||||
|
|
||||||
// Don't clear terminal for live streams - just append new content
|
// Don't clear terminal for live streams - just append new content
|
||||||
|
|
@ -212,17 +218,34 @@ export class Renderer {
|
||||||
console.log('Received header:', data);
|
console.log('Received header:', data);
|
||||||
this.resize(data.width, data.height);
|
this.resize(data.width, data.height);
|
||||||
} else if (Array.isArray(data) && data.length >= 3) {
|
} 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 = {
|
const castEvent: CastEvent = {
|
||||||
timestamp: data[0],
|
timestamp: data[0],
|
||||||
type: data[1],
|
type: data[1],
|
||||||
data: data[2]
|
data: data[2]
|
||||||
};
|
};
|
||||||
console.log('Received event:', castEvent.type, 'data length:', castEvent.data.length);
|
// Process event without verbose logging
|
||||||
// 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)));
|
|
||||||
}
|
|
||||||
this.processEvent(castEvent);
|
this.processEvent(castEvent);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -232,6 +255,13 @@ export class Renderer {
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('Stream error:', 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;
|
return eventSource;
|
||||||
|
|
@ -243,6 +273,7 @@ export class Renderer {
|
||||||
async loadFromUrl(url: string, isStream: boolean): Promise<void> {
|
async loadFromUrl(url: string, isStream: boolean): Promise<void> {
|
||||||
// Clean up existing connection
|
// Clean up existing connection
|
||||||
if (this.eventSource) {
|
if (this.eventSource) {
|
||||||
|
console.log('Explicitly closing existing EventSource connection');
|
||||||
this.eventSource.close();
|
this.eventSource.close();
|
||||||
this.eventSource = null;
|
this.eventSource = null;
|
||||||
}
|
}
|
||||||
|
|
@ -273,10 +304,13 @@ export class Renderer {
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this.eventSource) {
|
if (this.eventSource) {
|
||||||
|
console.log('Explicitly closing EventSource connection in dispose()');
|
||||||
this.eventSource.close();
|
this.eventSource.close();
|
||||||
this.eventSource = null;
|
this.eventSource = null;
|
||||||
}
|
}
|
||||||
this.terminal.dispose();
|
this.terminal.dispose();
|
||||||
|
Renderer.activeCount--;
|
||||||
|
console.log(`Renderer disposed (active: ${Renderer.activeCount})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to fit terminal to container (useful for responsive layouts)
|
// Method to fit terminal to container (useful for responsive layouts)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ interface ITerminalDimensions {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MINIMUM_ROWS = 1;
|
const MINIMUM_ROWS = 1;
|
||||||
|
const MIN_FONT_SIZE = 6;
|
||||||
|
const MAX_FONT_SIZE = 16;
|
||||||
|
|
||||||
export class ScaleFitAddon implements ITerminalAddon {
|
export class ScaleFitAddon implements ITerminalAddon {
|
||||||
private _terminal: Terminal | undefined;
|
private _terminal: Terminal | undefined;
|
||||||
|
|
@ -38,24 +40,26 @@ export class ScaleFitAddon implements ITerminalAddon {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get container dimensions
|
// Get the renderer container (parent of parent - the one with 10px padding)
|
||||||
const parentElement = this._terminal.element.parentElement;
|
const terminalWrapper = this._terminal.element.parentElement;
|
||||||
const parentStyle = window.getComputedStyle(parentElement);
|
const rendererContainer = terminalWrapper.parentElement;
|
||||||
const parentWidth = parseInt(parentStyle.getPropertyValue('width'));
|
|
||||||
const parentHeight = parseInt(parentStyle.getPropertyValue('height'));
|
|
||||||
|
|
||||||
// Get terminal element padding
|
if (!rendererContainer) return undefined;
|
||||||
const elementStyle = window.getComputedStyle(this._terminal.element);
|
|
||||||
const padding = {
|
// Get container dimensions and exact padding
|
||||||
top: parseInt(elementStyle.getPropertyValue('padding-top')),
|
const containerStyle = window.getComputedStyle(rendererContainer);
|
||||||
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
|
const containerWidth = parseInt(containerStyle.getPropertyValue('width'));
|
||||||
left: parseInt(elementStyle.getPropertyValue('padding-left')),
|
const containerHeight = parseInt(containerStyle.getPropertyValue('height'));
|
||||||
right: parseInt(elementStyle.getPropertyValue('padding-right'))
|
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
|
// Calculate exact available space using known padding
|
||||||
const availableWidth = parentWidth - padding.left - padding.right - 20; // Extra margin
|
const availableWidth = containerWidth - containerPadding.left - containerPadding.right;
|
||||||
const availableHeight = parentHeight - padding.top - padding.bottom - 20;
|
const availableHeight = containerHeight - containerPadding.top - containerPadding.bottom;
|
||||||
|
|
||||||
// Current terminal dimensions
|
// Current terminal dimensions
|
||||||
const currentCols = this._terminal.cols;
|
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
|
// Calculate optimal font size to fit current cols in available width
|
||||||
// Character width is approximately 0.6 * fontSize for monospace fonts
|
// Character width is approximately 0.6 * fontSize for monospace fonts
|
||||||
const charWidthRatio = 0.6;
|
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)
|
// 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)
|
// Get the actual line height from the rendered XTerm element
|
||||||
const lineHeight = optimalFontSize * 1.2;
|
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));
|
const optimalRows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / lineHeight));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -121,7 +131,8 @@ export class ScaleFitAddon implements ITerminalAddon {
|
||||||
|
|
||||||
const availableWidth = parentWidth - paddingHor;
|
const availableWidth = parentWidth - paddingHor;
|
||||||
const charWidthRatio = 0.6;
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -206,21 +206,64 @@ app.post('/api/sessions', async (req, res) => {
|
||||||
stdio: 'pipe'
|
stdio: 'pipe'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log output for debugging
|
// Capture session ID from stdout
|
||||||
|
let sessionId = '';
|
||||||
child.stdout.on('data', (data) => {
|
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) => {
|
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) => {
|
child.on('close', async (code) => {
|
||||||
console.log(`Session ${sessionName} exited with code: ${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
|
// Wait for session ID from tty-fwd or timeout after 3 seconds
|
||||||
res.json({ sessionId: sessionName });
|
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) {
|
} catch (error) {
|
||||||
console.error('Error creating session:', 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' });
|
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, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'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);
|
console.error('Error writing to client:', error);
|
||||||
if (streamInfo) {
|
if (streamInfo) {
|
||||||
streamInfo.clients.delete(client);
|
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('close', cleanup);
|
||||||
req.on('aborted', 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)
|
// 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,
|
'--session', sessionId,
|
||||||
'--send-key', text
|
'--send-key', text
|
||||||
]);
|
]);
|
||||||
console.log(`Successfully sent key: ${text} (${Date.now() - startTime}ms)`);
|
// Key sent successfully (removed verbose logging)
|
||||||
} else {
|
} else {
|
||||||
await executeTtyFwd([
|
await executeTtyFwd([
|
||||||
'--control-path', TTY_FWD_CONTROL_DIR,
|
'--control-path', TTY_FWD_CONTROL_DIR,
|
||||||
'--session', sessionId,
|
'--session', sessionId,
|
||||||
'--send-text', text
|
'--send-text', text
|
||||||
]);
|
]);
|
||||||
console.log(`Successfully sent text: ${text} (${Date.now() - startTime}ms)`);
|
// Text sent successfully (removed verbose logging)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue