Replace old renderer with new vibe-terminal in session view

- Replaced Renderer class with new vibe-terminal component for interactive sessions
- Integrated CastConverter.connectToStream() for real-time SSE streaming
- Added proper stream connection management with cleanup on disconnect
- Implemented automatic snapshot loading when sessions exit
- Updated copy functionality to work with DOM-based text selection
- Enhanced terminal initialization lifecycle with proper event handling
- Maintained all mobile input controls and keyboard functionality
- Fixed TypeScript type errors for viewport cleanup functionality
- Improved loading states and error handling for network issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-18 05:48:38 +02:00
parent e45371e9ca
commit 8ec8ca47d4

View file

@ -1,7 +1,9 @@
import { LitElement, PropertyValues, 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 './terminal.js';
import type { Terminal } from './terminal.js';
import { CastConverter } from '../utils/cast-converter.js';
@customElement('session-view') @customElement('session-view')
export class SessionView extends LitElement { export class SessionView extends LitElement {
@ -12,7 +14,9 @@ export class SessionView extends LitElement {
@property({ type: Object }) session: Session | null = null; @property({ type: Object }) session: Session | null = null;
@state() private connected = false; @state() private connected = false;
@state() private renderer: Renderer | null = null; @state() private terminal: Terminal | null = null;
@state() private streamConnection: { eventSource: EventSource; disconnect: () => void } | null =
null;
@state() private showMobileInput = false; @state() private showMobileInput = false;
@state() private mobileInputText = ''; @state() private mobileInputText = '';
@state() private isMobile = false; @state() private isMobile = false;
@ -118,18 +122,21 @@ export class SessionView extends LitElement {
// Stop loading animation // Stop loading animation
this.stopLoading(); this.stopLoading();
// Cleanup renderer if it exists // Cleanup stream connection if it exists
if (this.renderer) { if (this.streamConnection) {
this.renderer.dispose(); this.streamConnection.disconnect();
this.renderer = null; this.streamConnection = null;
} }
// Terminal cleanup is handled by the component itself
this.terminal = null;
} }
firstUpdated(changedProperties: PropertyValues) { firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties); super.firstUpdated(changedProperties);
if (this.session) { if (this.session) {
this.stopLoading(); this.stopLoading();
this.createInteractiveTerminal(); this.setupTerminal();
} }
} }
@ -139,7 +146,15 @@ export class SessionView extends LitElement {
// Stop loading and create terminal when session becomes available // Stop loading and create terminal when session becomes available
if (changedProperties.has('session') && this.session && this.loading) { if (changedProperties.has('session') && this.session && this.loading) {
this.stopLoading(); this.stopLoading();
this.createInteractiveTerminal(); this.setupTerminal();
}
// Initialize terminal after first render when terminal element exists
if (!this.terminal && this.session && !this.loading) {
const terminalElement = this.querySelector('vibe-terminal') as Terminal;
if (terminalElement) {
this.initializeTerminal();
}
} }
// Adjust terminal height for mobile buttons after render // Adjust terminal height for mobile buttons after render
@ -150,25 +165,34 @@ export class SessionView extends LitElement {
} }
} }
private createInteractiveTerminal() { private setupTerminal() {
if (!this.session) return; // Terminal element will be created in render()
// We'll initialize it in updated() after first render
}
// Look for existing interactive terminal div or create one private async initializeTerminal() {
let terminalElement = this.querySelector('#interactive-terminal') as HTMLElement; const terminalElement = this.querySelector('vibe-terminal') as Terminal;
if (!terminalElement) { if (!terminalElement || !this.session) return;
// Create the interactive terminal div inside the container
const container = this.querySelector('#terminal-container') as HTMLElement;
if (!container) return;
terminalElement = document.createElement('div'); this.terminal = terminalElement;
terminalElement.id = 'interactive-terminal';
terminalElement.className = 'w-full h-full';
terminalElement.style.cssText = 'max-width: 100%; height: 100%;';
container.appendChild(terminalElement);
}
// Create renderer once and connect to current session // Configure terminal for interactive session
this.renderer = new Renderer(terminalElement); this.terminal.cols = 80;
this.terminal.rows = 24;
this.terminal.fontSize = 14;
this.terminal.fitHorizontally = false; // Allow natural terminal sizing
// Listen for session exit events
this.terminal.addEventListener(
'session-exit',
this.handleSessionExit.bind(this) as EventListener
);
// Listen for terminal resize events to capture dimensions
this.terminal.addEventListener(
'terminal-resize',
this.handleTerminalResize.bind(this) as EventListener
);
// Wait a moment for freshly created sessions before connecting // Wait a moment for freshly created sessions before connecting
const sessionAge = Date.now() - new Date(this.session.startedAt).getTime(); const sessionAge = Date.now() - new Date(this.session.startedAt).getTime();
@ -180,23 +204,26 @@ export class SessionView extends LitElement {
} }
setTimeout(() => { setTimeout(() => {
if (this.renderer && this.session) { if (this.terminal && this.session) {
this.stopLoading(); // Stop loading before connecting this.stopLoading(); // Stop loading before connecting
this.renderer.connectToStream(this.session.id); this.connectToStream();
} }
}, delay); }, delay);
}
// Listen for session exit events private connectToStream() {
terminalElement.addEventListener( if (!this.terminal || !this.session) return;
'session-exit',
this.handleSessionExit.bind(this) as EventListener
);
// Listen for terminal resize events to capture dimensions // Clean up existing connection
terminalElement.addEventListener( if (this.streamConnection) {
'terminal-resize', this.streamConnection.disconnect();
this.handleTerminalResize.bind(this) as EventListener this.streamConnection = null;
); }
const streamUrl = `/api/sessions/${this.session.id}/stream`;
// Use CastConverter to connect terminal to stream
this.streamConnection = CastConverter.connectToStream(this.terminal, streamUrl);
} }
private async handleKeyboardInput(e: KeyboardEvent) { private async handleKeyboardInput(e: KeyboardEvent) {
@ -330,13 +357,44 @@ export class SessionView extends LitElement {
this.session = { ...this.session, status: 'exited' }; this.session = { ...this.session, status: 'exited' };
this.requestUpdate(); this.requestUpdate();
// Switch to snapshot mode // Switch to snapshot mode - disconnect stream and load final snapshot
if (this.streamConnection) {
this.streamConnection.disconnect();
this.streamConnection = null;
}
// Load final snapshot of the session
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.createInteractiveTerminal(); this.loadSessionSnapshot();
}); });
} }
} }
private async loadSessionSnapshot() {
if (!this.terminal || !this.session) return;
try {
const url = `/api/sessions/${this.session.id}/snapshot`;
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch snapshot: ${response.status}`);
const castContent = await response.text();
// Clear terminal and load snapshot
this.terminal.clear();
await CastConverter.dumpToTerminal(this.terminal, castContent);
// Scroll to bottom after loading
this.terminal.queueCallback(() => {
if (this.terminal) {
this.terminal.scrollToBottom();
}
});
} catch (error) {
console.error('Failed to load session snapshot:', error);
}
}
private handleTerminalResize(event: CustomEvent) { private handleTerminalResize(event: CustomEvent) {
// Update terminal dimensions for display // Update terminal dimensions for display
const { cols, rows } = event.detail; const { cols, rows } = event.detail;
@ -365,11 +423,13 @@ export class SessionView extends LitElement {
} else { } else {
// Clean up viewport listener when closing overlay // Clean up viewport listener when closing overlay
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement; const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
if ( if (textarea) {
textarea && const textareaWithCleanup = textarea as HTMLTextAreaElement & {
(textarea as HTMLTextAreaElement & { _viewportCleanup?: () => void })._viewportCleanup _viewportCleanup?: () => void;
) { };
(textarea as HTMLTextAreaElement & { _viewportCleanup?: () => void })._viewportCleanup(); if (textareaWithCleanup._viewportCleanup) {
textareaWithCleanup._viewportCleanup();
}
} }
} }
} }
@ -579,14 +639,15 @@ export class SessionView extends LitElement {
} }
private async handleCopy() { private async handleCopy() {
if (!this.renderer) return; if (!this.terminal) return;
try { try {
// Get the terminal instance from the renderer // Get selected text from terminal by querying the DOM
const terminal = this.renderer.getTerminal(); const terminalElement = this.querySelector('vibe-terminal');
if (!terminalElement) return;
// Get the selected text from the terminal const selection = window.getSelection();
const selectedText = terminal.getSelection(); const selectedText = selection?.toString() || '';
if (selectedText) { if (selectedText) {
// Write the selected text to clipboard // Write the selected text to clipboard
@ -602,27 +663,25 @@ export class SessionView extends LitElement {
console.error('Failed to copy to clipboard:', error); console.error('Failed to copy to clipboard:', error);
// Fallback: try to use the older document.execCommand method // Fallback: try to use the older document.execCommand method
try { try {
if (this.renderer) { const selection = window.getSelection();
const terminal = this.renderer.getTerminal(); const selectedText = selection?.toString() || '';
const selectedText = terminal.getSelection();
if (selectedText) { if (selectedText) {
const textArea = document.createElement('textarea'); const textArea = document.createElement('textarea');
textArea.value = selectedText; textArea.value = selectedText;
textArea.style.position = 'fixed'; textArea.style.position = 'fixed';
textArea.style.opacity = '0'; textArea.style.opacity = '0';
document.body.appendChild(textArea); document.body.appendChild(textArea);
textArea.select(); textArea.select();
if (document.execCommand('copy')) { if (document.execCommand('copy')) {
console.log( console.log(
'Text copied to clipboard (fallback):', 'Text copied to clipboard (fallback):',
selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : '') selectedText.substring(0, 50) + (selectedText.length > 50 ? '...' : '')
); );
}
document.body.removeChild(textArea);
} }
document.body.removeChild(textArea);
} }
} catch (fallbackError) { } catch (fallbackError) {
console.error('Fallback copy method also failed:', fallbackError); console.error('Fallback copy method also failed:', fallbackError);
@ -752,7 +811,7 @@ export class SessionView extends LitElement {
? html` ? html`
<!-- Loading overlay --> <!-- Loading overlay -->
<div <div
class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center" class="absolute inset-0 bg-black bg-opacity-80 flex items-center justify-center z-10"
> >
<div class="text-vs-text font-mono text-center"> <div class="text-vs-text font-mono text-center">
<div class="text-2xl mb-2">${this.getLoadingText()}</div> <div class="text-2xl mb-2">${this.getLoadingText()}</div>
@ -761,6 +820,15 @@ export class SessionView extends LitElement {
</div> </div>
` `
: ''} : ''}
<!-- Terminal Component -->
<vibe-terminal
.sessionId=${this.session?.id || ''}
.cols=${80}
.rows=${24}
.fontSize=${14}
.fitHorizontally=${false}
class="w-full h-full"
></vibe-terminal>
</div> </div>
<!-- Mobile Input Controls --> <!-- Mobile Input Controls -->