vibetunnel/web/src/client/components/session-card.ts

623 lines
20 KiB
TypeScript

/**
* 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`
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 8l-2 2m4-2l-2 2m4 0l-2 2"
opacity="0.6"
/>
</svg>
`;
@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<boolean> {
// 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`
<div
class="card cursor-pointer overflow-hidden flex flex-col h-full ${
this.killing ? 'opacity-60' : ''
} ${
this.isActive && this.session.status === 'running'
? 'ring-2 ring-primary shadow-glow-sm'
: ''
} ${this.selected ? 'ring-2 ring-accent-primary shadow-card-hover' : ''}"
style="view-transition-name: session-${this.session.id}; --session-id: session-${
this.session.id
}"
data-session-id="${this.session.id}"
data-testid="session-card"
data-session-status="${this.session.status}"
data-is-killing="${this.killing}"
@click=${this.handleCardClick}
@mouseenter=${this.handleMouseEnter}
@mouseleave=${this.handleMouseLeave}
>
<!-- Compact Header -->
<div
class="flex justify-between items-center px-3 py-2 border-b border-base bg-gradient-to-r from-secondary to-tertiary"
>
<div class="text-xs font-mono pr-2 flex-1 min-w-0 text-primary">
<inline-edit
.value=${this.session.name || this.session.command?.join(' ') || ''}
.placeholder=${this.session.command?.join(' ') || ''}
.onSave=${async (newName: string) => {
try {
await this.handleRename(newName);
} catch (error) {
// Error is already handled in handleRename
logger.debug('Rename error caught in onSave', { error });
}
}}
></inline-edit>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
${
this.session.status === 'running' && isAIAssistantSession(this.session)
? html`
<button
class="bg-transparent border-0 p-0 cursor-pointer opacity-50 hover:opacity-100 transition-opacity duration-200 text-primary"
@click=${(e: Event) => {
e.stopPropagation();
this.handleMagicButton();
}}
id="session-magic-button"
title="Send prompt to update terminal title"
aria-label="Send magic prompt to AI assistant"
?disabled=${this.isSendingPrompt}
>
${
this.isSendingPrompt
? html`<span class="block w-5 h-5 flex items-center justify-center animate-spin">⠋</span>`
: MAGIC_WAND_ICON
}
</button>
`
: ''
}
${
this.session.status === 'running' || this.session.status === 'exited'
? html`
<button
class="p-1 rounded-full transition-all duration-200 disabled:opacity-50 flex-shrink-0 ${
this.session.status === 'running'
? 'text-status-error hover:bg-status-error hover:bg-opacity-20'
: 'text-status-warning hover:bg-status-warning hover:bg-opacity-20'
}"
@click=${this.handleKillClick}
?disabled=${this.killing}
id="session-kill-button"
title="${this.session.status === 'running' ? 'Kill session' : 'Clean up session'}"
data-testid="kill-session-button"
>
${
this.killing
? html`<span class="block w-5 h-5 flex items-center justify-center"
>${this.getKillingText()}</span
>`
: html`
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="10" stroke-width="2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 9l-6 6m0-6l6 6"
/>
</svg>
`
}
</button>
`
: ''
}
</div>
</div>
<!-- Terminal display (main content) -->
<div
class="session-preview bg-bg overflow-hidden flex-1 relative ${
this.session.status === 'exited' ? 'session-exited' : ''
}"
style="background: linear-gradient(to bottom, rgb(var(--color-bg)), rgb(var(--color-bg-secondary))); box-shadow: inset 0 1px 3px rgb(var(--color-bg) / 0.5);"
>
${
this.killing
? html`
<div class="w-full h-full flex items-center justify-center text-status-error">
<div class="text-center font-mono">
<div class="text-4xl mb-2">${this.getKillingText()}</div>
<div class="text-sm">Killing session...</div>
</div>
</div>
`
: html`
<vibe-terminal-buffer
.sessionId=${this.session.id}
.theme=${this.terminalTheme}
class="w-full h-full"
style="pointer-events: none;"
@content-changed=${this.handleContentChanged}
></vibe-terminal-buffer>
`
}
</div>
<!-- Compact Footer -->
<div
class="px-3 py-2 text-muted text-xs border-t border-base bg-gradient-to-r from-tertiary to-secondary"
>
<div class="flex justify-between items-center min-w-0">
<span
class="${this.getActivityStatusColor()} text-xs flex items-center gap-1 flex-shrink-0"
data-status="${this.session.status}"
data-killing="${this.killing}"
>
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
${this.getActivityStatusText()}
${
this.session.status === 'running' &&
this.isActive &&
!this.session.activityStatus?.specificStatus
? html`<span class="text-primary animate-pulse ml-1">●</span>`
: ''
}
</span>
${
this.session.pid
? html`
<span
class="cursor-pointer hover:text-primary transition-colors text-xs flex-shrink-0 ml-2 inline-flex items-center gap-1"
id="session-pid-copy"
@click=${this.handlePidClick}
title="Click to copy PID"
>
PID: ${this.session.pid} <copy-icon size="14"></copy-icon>
</span>
`
: ''
}
</div>
<div class="text-xs opacity-75 min-w-0 mt-1">
<clickable-path .path=${this.session.workingDir} .iconSize=${12}></clickable-path>
</div>
</div>
</div>
`;
}
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';
}
}