mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix terminal layout and improve mobile UX
- Restructure session-view to use proper flexbox layout (header/xterm/buttons) - Container now exactly fits viewport height (100vh) - Header shows command and working directory stacked - XTerm gets all remaining space with horizontal scrolling - Mobile buttons integrated into layout instead of overlaying - Replace setTimeout with requestAnimationFrame for better performance - Add isPreview parameter to renderer for future preview scaling - Disable pointer events on preview terminals so clicks pass through 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
093aff4379
commit
34801bc687
3 changed files with 70 additions and 46 deletions
|
|
@ -145,8 +145,9 @@ export class SessionList extends LitElement {
|
||||||
const session = this.sessions.find(s => s.id === sessionId);
|
const session = this.sessions.find(s => s.id === sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
// Create renderer with smaller dimensions and font for preview
|
// Create renderer with smaller dimensions for preview
|
||||||
const renderer = new Renderer(playerElement, 40, 12, 10000, 8); // 40x12 chars, 8px font
|
// 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);
|
this.renderers.set(sessionId, renderer);
|
||||||
|
|
||||||
// Terminal is already configured with disableStdin: true in renderer constructor
|
// Terminal is already configured with disableStdin: true in renderer constructor
|
||||||
|
|
@ -159,6 +160,11 @@ export class SessionList extends LitElement {
|
||||||
|
|
||||||
// Let the renderer handle the URL
|
// Let the renderer handle the URL
|
||||||
await renderer.loadFromUrl(url, isStream);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error creating renderer:', error);
|
console.error('Error creating renderer:', error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,17 @@ export class SessionView extends LitElement {
|
||||||
|
|
||||||
if (changedProperties.has('session') && this.session) {
|
if (changedProperties.has('session') && this.session) {
|
||||||
this.createInteractiveTerminal();
|
this.createInteractiveTerminal();
|
||||||
|
// Adjust terminal spacing after creating terminal
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.adjustTerminalForMobileButtons();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust terminal height for mobile buttons after render
|
||||||
|
if (changedProperties.has('showMobileInput') || changedProperties.has('isMobile')) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.adjustTerminalForMobileButtons();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,13 +241,13 @@ export class SessionView extends LitElement {
|
||||||
this.showMobileInput = !this.showMobileInput;
|
this.showMobileInput = !this.showMobileInput;
|
||||||
if (this.showMobileInput) {
|
if (this.showMobileInput) {
|
||||||
// Focus the textarea after a short delay to ensure it's rendered
|
// Focus the textarea after a short delay to ensure it's rendered
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
|
const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement;
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
this.adjustTextareaForKeyboard();
|
this.adjustTextareaForKeyboard();
|
||||||
}
|
}
|
||||||
}, 100);
|
});
|
||||||
} 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;
|
||||||
|
|
@ -315,7 +326,7 @@ export class SessionView extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial adjustment
|
// Initial adjustment
|
||||||
setTimeout(adjustLayout, 300);
|
requestAnimationFrame(adjustLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMobileInputChange(e: Event) {
|
private handleMobileInputChange(e: Event) {
|
||||||
|
|
@ -403,6 +414,11 @@ export class SessionView extends LitElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private adjustTerminalForMobileButtons() {
|
||||||
|
// Disabled for now to avoid viewport issues
|
||||||
|
// The mobile buttons will overlay the terminal
|
||||||
|
}
|
||||||
|
|
||||||
private startSessionStatusPolling() {
|
private startSessionStatusPolling() {
|
||||||
if (this.sessionStatusInterval) {
|
if (this.sessionStatusInterval) {
|
||||||
clearInterval(this.sessionStatusInterval);
|
clearInterval(this.sessionStatusInterval);
|
||||||
|
|
@ -468,7 +484,7 @@ export class SessionView extends LitElement {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="h-screen flex flex-col bg-vs-bg font-mono" style="outline: none !important; box-shadow: none !important;">
|
<div class="flex flex-col bg-vs-bg font-mono" style="height: 100vh; outline: none !important; box-shadow: none !important;">
|
||||||
<!-- Compact Header -->
|
<!-- Compact Header -->
|
||||||
<div class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm">
|
<div class="flex items-center justify-between px-3 py-2 border-b border-vs-border bg-vs-bg-secondary text-sm">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|
@ -479,14 +495,11 @@ export class SessionView extends LitElement {
|
||||||
BACK
|
BACK
|
||||||
</button>
|
</button>
|
||||||
<div class="text-vs-text">
|
<div class="text-vs-text">
|
||||||
<span class="text-vs-accent">${this.session.command}</span>
|
<div class="text-vs-accent">${this.session.command}</div>
|
||||||
<span class="text-vs-muted text-xs ml-2">(${this.session.id.substring(0, 8)}...)</span>
|
<div class="text-vs-muted text-xs">${this.session.workingDir}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 text-xs">
|
<div class="flex items-center gap-3 text-xs">
|
||||||
<span class="text-vs-muted">
|
|
||||||
${this.session.workingDir}
|
|
||||||
</span>
|
|
||||||
<span class="${this.session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'}">
|
<span class="${this.session.status === 'running' ? 'text-vs-user' : 'text-vs-warning'}">
|
||||||
${this.session.status.toUpperCase()}
|
${this.session.status.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -494,15 +507,13 @@ export class SessionView extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Terminal Container -->
|
<!-- Terminal Container -->
|
||||||
<div class="flex-1 bg-black overflow-hidden">
|
<div class="flex-1 bg-black overflow-x-auto overflow-y-hidden min-h-0" id="terminal-container">
|
||||||
<div id="interactive-terminal" class="w-full h-full"></div>
|
<div id="interactive-terminal" class="w-full h-full"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Input Controls -->
|
<!-- Mobile Input Controls -->
|
||||||
${this.isMobile ? html`
|
${this.isMobile && !this.showMobileInput ? html`
|
||||||
<!-- Quick Action Buttons (only when overlay is closed) -->
|
<div class="flex-shrink-0 p-4 bg-vs-bg">
|
||||||
${!this.showMobileInput ? html`
|
|
||||||
<div class="fixed bottom-4 left-4 right-4 z-40">
|
|
||||||
<!-- First row: Arrow keys -->
|
<!-- First row: Arrow keys -->
|
||||||
<div class="flex gap-2 mb-2">
|
<div class="flex gap-2 mb-2">
|
||||||
<button
|
<button
|
||||||
|
|
@ -564,11 +575,11 @@ export class SessionView extends LitElement {
|
||||||
TYPE
|
TYPE
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<!-- Full-Screen Input Overlay (only when opened) -->
|
<!-- Full-Screen Input Overlay (only when opened) -->
|
||||||
${this.showMobileInput ? html`
|
${this.isMobile && this.showMobileInput ? html`
|
||||||
<div class="fixed inset-0 bg-vs-bg-secondary bg-opacity-95 z-50 flex flex-col" style="height: 100vh; height: 100dvh;">
|
<div class="fixed inset-0 bg-vs-bg-secondary bg-opacity-95 z-50 flex flex-col" style="height: 100vh; height: 100dvh;">
|
||||||
<!-- Input Header -->
|
<!-- Input Header -->
|
||||||
<div class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0">
|
<div class="flex items-center justify-between p-4 border-b border-vs-border flex-shrink-0">
|
||||||
|
|
@ -627,7 +638,6 @@ export class SessionView extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,12 @@ export class Renderer {
|
||||||
private terminal: Terminal;
|
private terminal: Terminal;
|
||||||
private fitAddon: FitAddon;
|
private fitAddon: FitAddon;
|
||||||
private webLinksAddon: WebLinksAddon;
|
private webLinksAddon: WebLinksAddon;
|
||||||
|
private isPreview: boolean;
|
||||||
|
|
||||||
constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14) {
|
constructor(container: HTMLElement, width: number = 80, height: number = 20, scrollback: number = 1000000, fontSize: number = 14, isPreview: boolean = false) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
|
this.isPreview = isPreview;
|
||||||
|
|
||||||
// Create terminal with options similar to the custom renderer
|
// Create terminal with options similar to the custom renderer
|
||||||
this.terminal = new Terminal({
|
this.terminal = new Terminal({
|
||||||
cols: width,
|
cols: width,
|
||||||
|
|
@ -65,13 +67,13 @@ export class Renderer {
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
altClickMovesCursor: false,
|
altClickMovesCursor: false,
|
||||||
rightClickSelectsWord: false,
|
rightClickSelectsWord: false,
|
||||||
disableStdin: true // We handle input separately
|
disableStdin: true, // We handle input separately
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add addons
|
// Add addons
|
||||||
this.fitAddon = new FitAddon();
|
this.fitAddon = new FitAddon();
|
||||||
this.webLinksAddon = new WebLinksAddon();
|
this.webLinksAddon = new WebLinksAddon();
|
||||||
|
|
||||||
this.terminal.loadAddon(this.fitAddon);
|
this.terminal.loadAddon(this.fitAddon);
|
||||||
this.terminal.loadAddon(this.webLinksAddon);
|
this.terminal.loadAddon(this.webLinksAddon);
|
||||||
|
|
||||||
|
|
@ -84,7 +86,7 @@ export class Renderer {
|
||||||
this.container.style.padding = '10px';
|
this.container.style.padding = '10px';
|
||||||
this.container.style.backgroundColor = '#1e1e1e';
|
this.container.style.backgroundColor = '#1e1e1e';
|
||||||
this.container.style.overflow = 'hidden';
|
this.container.style.overflow = 'hidden';
|
||||||
|
|
||||||
// Create terminal wrapper
|
// Create terminal wrapper
|
||||||
const terminalWrapper = document.createElement('div');
|
const terminalWrapper = document.createElement('div');
|
||||||
terminalWrapper.style.width = '100%';
|
terminalWrapper.style.width = '100%';
|
||||||
|
|
@ -93,10 +95,10 @@ export class Renderer {
|
||||||
|
|
||||||
// Open terminal in the wrapper
|
// Open terminal in the wrapper
|
||||||
this.terminal.open(terminalWrapper);
|
this.terminal.open(terminalWrapper);
|
||||||
|
|
||||||
// Fit terminal to container
|
// Just use FitAddon
|
||||||
this.fitAddon.fit();
|
this.fitAddon.fit();
|
||||||
|
|
||||||
// Handle container resize
|
// Handle container resize
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
this.fitAddon.fit();
|
this.fitAddon.fit();
|
||||||
|
|
@ -104,6 +106,7 @@ export class Renderer {
|
||||||
resizeObserver.observe(this.container);
|
resizeObserver.observe(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Public API methods - maintain compatibility with custom renderer
|
// Public API methods - maintain compatibility with custom renderer
|
||||||
|
|
||||||
async loadCastFile(url: string): Promise<void> {
|
async loadCastFile(url: string): Promise<void> {
|
||||||
|
|
@ -115,16 +118,16 @@ export class Renderer {
|
||||||
parseCastFile(content: string): void {
|
parseCastFile(content: string): void {
|
||||||
const lines = content.trim().split('\n');
|
const lines = content.trim().split('\n');
|
||||||
let header: CastHeader | null = null;
|
let header: CastHeader | null = null;
|
||||||
|
|
||||||
// Clear terminal
|
// Clear terminal
|
||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(line);
|
const parsed = JSON.parse(line);
|
||||||
|
|
||||||
if (parsed.version && parsed.width && parsed.height) {
|
if (parsed.version && parsed.width && parsed.height) {
|
||||||
// Header
|
// Header
|
||||||
header = parsed;
|
header = parsed;
|
||||||
|
|
@ -136,7 +139,7 @@ export class Renderer {
|
||||||
type: parsed[1],
|
type: parsed[1],
|
||||||
data: parsed[2]
|
data: parsed[2]
|
||||||
};
|
};
|
||||||
|
|
||||||
if (event.type === 'o') {
|
if (event.type === 'o') {
|
||||||
this.processOutput(event.data);
|
this.processOutput(event.data);
|
||||||
} else if (event.type === 'r') {
|
} else if (event.type === 'r') {
|
||||||
|
|
@ -173,11 +176,8 @@ export class Renderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
resize(width: number, height: number): void {
|
resize(width: number, height: number): void {
|
||||||
this.terminal.resize(width, height);
|
// Ignore session resize and just use FitAddon
|
||||||
// Fit addon will handle the visual resize
|
this.fitAddon.fit();
|
||||||
setTimeout(() => {
|
|
||||||
this.fitAddon.fit();
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
|
|
@ -192,13 +192,13 @@ export class Renderer {
|
||||||
// Connect to any SSE URL
|
// Connect to any SSE URL
|
||||||
connectToUrl(url: string): EventSource {
|
connectToUrl(url: string): EventSource {
|
||||||
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
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
if (data.version && data.width && data.height) {
|
if (data.version && data.width && data.height) {
|
||||||
// Header
|
// Header
|
||||||
console.log('Received header:', data);
|
console.log('Received header:', data);
|
||||||
|
|
@ -221,11 +221,11 @@ export class Renderer {
|
||||||
console.warn('Failed to parse stream event:', event.data);
|
console.warn('Failed to parse stream event:', event.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('Stream error:', error);
|
console.error('Stream error:', error);
|
||||||
};
|
};
|
||||||
|
|
||||||
return eventSource;
|
return eventSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,7 +238,7 @@ export class Renderer {
|
||||||
this.eventSource.close();
|
this.eventSource.close();
|
||||||
this.eventSource = null;
|
this.eventSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isStream) {
|
if (isStream) {
|
||||||
// It's a stream URL, connect via SSE (don't clear - append to existing content)
|
// It's a stream URL, connect via SSE (don't clear - append to existing content)
|
||||||
this.eventSource = this.connectToUrl(url);
|
this.eventSource = this.connectToUrl(url);
|
||||||
|
|
@ -250,7 +250,7 @@ export class Renderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional methods for terminal control
|
// Additional methods for terminal control
|
||||||
|
|
||||||
focus(): void {
|
focus(): void {
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -303,4 +303,12 @@ export class Renderer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable all pointer events for previews so clicks pass through to parent
|
||||||
|
setPointerEventsEnabled(enabled: boolean): void {
|
||||||
|
const terminalElement = this.container.querySelector('.xterm') as HTMLElement;
|
||||||
|
if (terminalElement) {
|
||||||
|
terminalElement.style.pointerEvents = enabled ? 'auto' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue