mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
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:
parent
e45371e9ca
commit
8ec8ca47d4
1 changed files with 135 additions and 67 deletions
|
|
@ -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 -->
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue