/** * Session Card Component * * Displays a single terminal session with its preview, status, and controls. * Shows activity indicators when terminal content changes and provides kill functionality. * * @fires session-select - When card is clicked (detail: Session) * @fires session-killed - When session is successfully killed (detail: { sessionId: string, session: Session }) * @fires session-kill-error - When kill operation fails (detail: { sessionId: string, error: string }) * * @listens content-changed - From vibe-terminal-buffer when terminal content changes */ import { html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import type { Session } from '../../shared/types.js'; import type { AuthClient } from '../services/auth-client.js'; import { sessionActionService } from '../services/session-action-service.js'; import { isAIAssistantSession, sendAIPrompt } from '../utils/ai-sessions.js'; import { createLogger } from '../utils/logger.js'; import { copyToClipboard } from '../utils/path-utils.js'; import { TerminalPreferencesManager } from '../utils/terminal-preferences.js'; import type { TerminalThemeId } from '../utils/terminal-themes.js'; const logger = createLogger('session-card'); import './vibe-terminal-buffer.js'; import './copy-icon.js'; import './clickable-path.js'; import './inline-edit.js'; // Magic wand icon constant const MAGIC_WAND_ICON = html` `; @customElement('session-card') export class SessionCard extends LitElement { // Disable shadow DOM to use Tailwind createRenderRoot() { return this; } @property({ type: Object }) session!: Session; @property({ type: Object }) authClient!: AuthClient; @property({ type: Boolean }) selected = false; @state() private killing = false; @state() private killingFrame = 0; @state() private isActive = false; @state() private isHovered = false; @state() private isSendingPrompt = false; @state() private terminalTheme: TerminalThemeId = 'auto'; private killingInterval: number | null = null; private activityTimeout: number | null = null; private storageListener: ((e: StorageEvent) => void) | null = null; private themeChangeListener: ((e: CustomEvent) => void) | null = null; private preferencesManager = TerminalPreferencesManager.getInstance(); connectedCallback() { super.connectedCallback(); // Load initial theme from TerminalPreferencesManager this.loadThemeFromStorage(); // Listen for storage changes to update theme reactively (cross-tab) this.storageListener = (e: StorageEvent) => { if (e.key === 'vibetunnel_terminal_preferences') { this.loadThemeFromStorage(); } }; window.addEventListener('storage', this.storageListener); // Listen for custom theme change events (same-tab) this.themeChangeListener = (e: CustomEvent) => { this.terminalTheme = e.detail as TerminalThemeId; }; window.addEventListener('terminal-theme-changed', this.themeChangeListener as EventListener); } disconnectedCallback() { super.disconnectedCallback(); if (this.killingInterval) { clearInterval(this.killingInterval); } if (this.activityTimeout) { clearTimeout(this.activityTimeout); } if (this.storageListener) { window.removeEventListener('storage', this.storageListener); this.storageListener = null; } if (this.themeChangeListener) { window.removeEventListener( 'terminal-theme-changed', this.themeChangeListener as EventListener ); this.themeChangeListener = null; } } private handleCardClick() { this.dispatchEvent( new CustomEvent('session-select', { detail: this.session, bubbles: true, composed: true, }) ); } private handleContentChanged() { // Only track activity for running sessions if (this.session.status !== 'running') { return; } // Content changed, immediately mark as active this.isActive = true; // Clear existing timeout if (this.activityTimeout) { clearTimeout(this.activityTimeout); } // Set timeout to clear activity after 500ms of no changes this.activityTimeout = window.setTimeout(() => { this.isActive = false; this.activityTimeout = null; }, 500); } private async handleKillClick(e: Event) { e.stopPropagation(); e.preventDefault(); await this.kill(); } // Public method to kill the session with animation (or clean up exited session) public async kill(): Promise { // Don't kill if already killing if (this.killing) { return false; } // Only allow killing/cleanup for running or exited sessions if (this.session.status !== 'running' && this.session.status !== 'exited') { return false; } // Check if this is a cleanup action (for black hole animation) const isCleanup = this.session.status === 'exited'; // Start killing animation this.killing = true; this.killingFrame = 0; this.killingInterval = window.setInterval(() => { this.killingFrame = (this.killingFrame + 1) % 4; this.requestUpdate(); }, 200); // Set a timeout to prevent getting stuck in killing state const killingTimeout = setTimeout(() => { logger.warn(`Kill operation timed out for session ${this.session.id}`); this.stopKillingAnimation(); // Dispatch error event this.dispatchEvent( new CustomEvent('session-kill-error', { detail: { sessionId: this.session.id, error: 'Kill operation timed out', }, bubbles: true, composed: true, }) ); }, 10000); // 10 second timeout // If cleanup, apply black hole animation FIRST and wait if (isCleanup) { // Apply the black hole animation class (this as HTMLElement).classList.add('black-hole-collapsing'); // Wait for the animation to complete (300ms) await new Promise((resolve) => setTimeout(resolve, 300)); } // Send kill or cleanup request based on session status const isExited = this.session.status === 'exited'; const result = await sessionActionService.deleteSession(this.session, { authClient: this.authClient, callbacks: { onError: (errorMessage) => { logger.error('Error killing session', { error: errorMessage, sessionId: this.session.id, }); // Show error to user (keep animation to indicate something went wrong) this.dispatchEvent( new CustomEvent('session-kill-error', { detail: { sessionId: this.session.id, error: errorMessage, }, bubbles: true, composed: true, }) ); clearTimeout(killingTimeout); }, onSuccess: () => { // Kill/cleanup succeeded - dispatch event to notify parent components this.dispatchEvent( new CustomEvent('session-killed', { detail: { sessionId: this.session.id, session: this.session, }, bubbles: true, composed: true, }) ); logger.log( `Session ${this.session.id} ${isExited ? 'cleaned up' : 'killed'} successfully` ); clearTimeout(killingTimeout); }, }, }); // Stop animation in all cases this.stopKillingAnimation(); clearTimeout(killingTimeout); return result.success; } private stopKillingAnimation() { this.killing = false; if (this.killingInterval) { clearInterval(this.killingInterval); this.killingInterval = null; } } private getKillingText(): string { const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; return frames[this.killingFrame % frames.length]; } private async handleRename(newName: string) { try { const response = await fetch(`/api/sessions/${this.session.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...this.authClient.getAuthHeader(), }, body: JSON.stringify({ name: newName }), }); if (!response.ok) { const errorData = await response.text(); logger.error('Failed to rename session', { errorData, sessionId: this.session.id }); throw new Error(`Rename failed: ${response.status}`); } // Update the local session object this.session = { ...this.session, name: newName }; // Dispatch event to notify parent components this.dispatchEvent( new CustomEvent('session-renamed', { detail: { sessionId: this.session.id, newName: newName, }, bubbles: true, composed: true, }) ); logger.log(`Session ${this.session.id} renamed to: ${newName}`); } catch (error) { logger.error('Error renaming session', { error, sessionId: this.session.id }); // Show error to user this.dispatchEvent( new CustomEvent('session-rename-error', { detail: { sessionId: this.session.id, error: error instanceof Error ? error.message : 'Unknown error', }, bubbles: true, composed: true, }) ); } } private async handlePidClick(e: Event) { e.stopPropagation(); e.preventDefault(); if (this.session.pid) { const success = await copyToClipboard(this.session.pid.toString()); if (success) { logger.log('PID copied to clipboard', { pid: this.session.pid }); } else { logger.error('Failed to copy PID to clipboard', { pid: this.session.pid }); } } } private async handleMagicButton() { if (!this.session || this.isSendingPrompt) return; this.isSendingPrompt = true; logger.log('Magic button clicked for session', this.session.id); try { await sendAIPrompt(this.session.id, this.authClient); } catch (error) { logger.error('Failed to send AI prompt', error); this.dispatchEvent( new CustomEvent('show-toast', { detail: { message: 'Failed to send prompt to AI assistant', type: 'error', }, bubbles: true, composed: true, }) ); } finally { this.isSendingPrompt = false; } } private handleMouseEnter() { this.isHovered = true; } private handleMouseLeave() { this.isHovered = false; } private loadThemeFromStorage() { this.terminalTheme = this.preferencesManager.getTheme(); } render() { // Debug logging to understand what's in the session if (!this.session.name) { logger.warn('Session missing name', { sessionId: this.session.id, name: this.session.name, command: this.session.command, }); } return html`
{ try { await this.handleRename(newName); } catch (error) { // Error is already handled in handleRename logger.debug('Rename error caught in onSave', { error }); } }} >
${ this.session.status === 'running' && isAIAssistantSession(this.session) ? html` ` : '' } ${ this.session.status === 'running' || this.session.status === 'exited' ? html` ` : '' }
${ this.killing ? html`
${this.getKillingText()}
Killing session...
` : html` ` }
${this.getActivityStatusText()} ${ this.session.status === 'running' && this.isActive && !this.session.activityStatus?.specificStatus ? html`` : '' }
${ this.session.pid ? html` PID: ${this.session.pid} ` : '' }
`; } private getStatusText(): string { if (this.session.active === false) { return 'waiting'; } return this.session.status; } private getActivityStatusText(): string { if (this.killing) { return 'killing...'; } if (this.session.active === false) { return 'waiting'; } if (this.session.status === 'running' && this.session.activityStatus?.specificStatus) { return this.session.activityStatus.specificStatus.status; } return this.session.status; } private getStatusColor(): string { if (this.killing) { return 'text-status-error'; } if (this.session.active === false) { return 'text-muted'; } return this.session.status === 'running' ? 'text-status-success' : 'text-status-warning'; } private getActivityStatusColor(): string { if (this.killing) { return 'text-status-error'; } if (this.session.active === false) { return 'text-muted'; } if (this.session.status === 'running' && this.session.activityStatus?.specificStatus) { return 'text-status-warning'; } return this.session.status === 'running' ? 'text-status-success' : 'text-status-warning'; } private getStatusDotColor(): string { if (this.killing) { return 'bg-status-error animate-pulse'; } if (this.session.active === false) { return 'bg-muted'; } if (this.session.status === 'running') { if (this.session.activityStatus?.specificStatus) { return 'bg-status-warning animate-pulse'; // Claude active - amber with pulse } else if (this.session.activityStatus?.isActive || this.isActive) { return 'bg-status-success'; // Generic active - solid green } else { return 'bg-status-success ring-1 ring-status-success ring-opacity-50'; // Idle - green with ring } } return 'bg-status-warning'; } }