feat(web): implement vertical tabs with Arc-style persistent sidebar and comprehensive mobile UX

- Adds Arc-style persistent sidebar navigation with resizable vertical tabs
- Implements comprehensive mobile responsiveness with slide animations and hamburger menu
- Creates utility modules for responsive design, constants, and terminal management
- Refactors header components into specialized classes for better separation of concerns
- Implements ResizeObserver-based responsive design system for efficient viewport tracking
- Fixes mobile scrolling issues and eliminates layout shift bugs
- Improves session card consistency and status indicator positioning
- Adds proper terminal resize events when switching between sessions
- Enhances sidebar UX with compact headers, uniform borders, and smooth transitions
- Centralizes UI constants and breakpoints for maintainable responsive design
- Resolves TypeScript errors with web-push dependency reinstallation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Manuel Maly 2025-06-23 16:12:06 +02:00 committed by Peter Steinberger
parent 6b71cd79f0
commit bc370452ad
12 changed files with 1341 additions and 400 deletions

View file

@ -11,6 +11,11 @@ import { createLogger } from './utils/logger.js';
// Import version
import { VERSION } from './version.js';
// Import utilities
import { BREAKPOINTS, SIDEBAR, TRANSITIONS, TIMING } from './utils/constants.js';
import { triggerTerminalResize } from './utils/terminal-utils.js';
import { responsiveObserver, type MediaQueryState } from './utils/responsive-utils.js';
// Import components
import './components/app-header.js';
import './components/session-create-form.js';
@ -55,18 +60,27 @@ export class VibeTunnelApp extends LitElement {
@state() private showNotificationSettings = false;
@state() private showSSHKeyManager = false;
@state() private isAuthenticated = false;
@state() private sidebarCollapsed = this.loadSidebarState();
@state() private sidebarWidth = this.loadSidebarWidth();
@state() private isResizing = false;
@state() private mediaState: MediaQueryState = responsiveObserver.getCurrentState();
private initialLoadComplete = false;
private authClient = new AuthClient();
private responsiveObserverInitialized = false;
private hotReloadWs: WebSocket | null = null;
private errorTimeoutId: number | null = null;
private successTimeoutId: number | null = null;
private autoRefreshIntervalId: number | null = null;
private responsiveUnsubscribe?: () => void;
private resizeCleanupFunctions: (() => void)[] = [];
connectedCallback() {
super.connectedCallback();
this.setupHotReload();
this.setupKeyboardShortcuts();
this.setupNotificationHandlers();
this.setupResponsiveObserver();
// Initialize authentication and routing together
this.initializeApp();
}
@ -80,6 +94,17 @@ export class VibeTunnelApp extends LitElement {
window.removeEventListener('popstate', this.handlePopState);
// Clean up keyboard shortcuts
window.removeEventListener('keydown', this.handleKeyDown);
// Clean up auto refresh interval
if (this.autoRefreshIntervalId !== null) {
clearInterval(this.autoRefreshIntervalId);
this.autoRefreshIntervalId = null;
}
// Clean up responsive observer
if (this.responsiveUnsubscribe) {
this.responsiveUnsubscribe();
}
// Clean up any active resize listeners
this.cleanupResizeListeners();
}
private handleKeyDown = (e: KeyboardEvent) => {
@ -88,6 +113,18 @@ export class VibeTunnelApp extends LitElement {
e.preventDefault();
this.showFileBrowser = true;
}
// Handle Escape to close the session and return to list view
if (
e.key === 'Escape' &&
this.currentView === 'session' &&
!this.showFileBrowser &&
!this.showCreateModal
) {
e.preventDefault();
this.handleNavigateToList();
}
};
private setupKeyboardShortcuts() {
@ -179,11 +216,11 @@ export class VibeTunnelApp extends LitElement {
}
this.errorMessage = message;
// Clear error after 5 seconds
// Clear error after configured timeout
this.errorTimeoutId = window.setTimeout(() => {
this.errorMessage = '';
this.errorTimeoutId = null;
}, 5000);
}, TIMING.ERROR_MESSAGE_TIMEOUT);
}
private showSuccess(message: string) {
@ -194,11 +231,11 @@ export class VibeTunnelApp extends LitElement {
}
this.successMessage = message;
// Clear success after 5 seconds
// Clear success after configured timeout
this.successTimeoutId = window.setTimeout(() => {
this.successMessage = '';
this.successTimeoutId = null;
}, 5000);
}, TIMING.SUCCESS_MESSAGE_TIMEOUT);
}
private clearError() {
@ -245,12 +282,12 @@ export class VibeTunnelApp extends LitElement {
}
private startAutoRefresh() {
// Refresh sessions every 3 seconds, but only when showing session list
setInterval(() => {
// Refresh sessions at configured interval, but only when showing session list
this.autoRefreshIntervalId = window.setInterval(() => {
if (this.currentView === 'list') {
this.loadSessions();
}
}, 3000);
}, TIMING.AUTO_REFRESH_INTERVAL);
}
private async handleSessionCreated(e: CustomEvent) {
@ -277,7 +314,7 @@ export class VibeTunnelApp extends LitElement {
private async waitForSessionAndSwitch(sessionId: string) {
const maxAttempts = 10;
const delay = 500; // 500ms between attempts
const delay = TIMING.SESSION_SEARCH_DELAY; // Configured delay between attempts
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await this.loadSessions();
@ -296,7 +333,7 @@ export class VibeTunnelApp extends LitElement {
}
// Wait before next attempt
await new Promise((resolve) => setTimeout(resolve, delay));
await new Promise((resolve) => window.setTimeout(resolve, delay));
}
// If we get here, session creation might have failed
@ -361,6 +398,16 @@ export class VibeTunnelApp extends LitElement {
this.cleanupSessionViewStream();
}
// Debug: Log current state before navigation
logger.debug('Navigation to session:', {
sessionId,
windowWidth: window.innerWidth,
mobileBreakpoint: BREAKPOINTS.MOBILE,
isMobile: this.mediaState.isMobile,
currentSidebarCollapsed: this.sidebarCollapsed,
mediaStateIsMobile: this.mediaState.isMobile,
});
// Check if View Transitions API is supported
if ('startViewTransition' in document && typeof document.startViewTransition === 'function') {
// Debug: Check what elements have view-transition-name before transition
@ -376,9 +423,18 @@ export class VibeTunnelApp extends LitElement {
this.currentView = 'session';
this.updateUrl(sessionId);
// Collapse sidebar on mobile after selecting a session
if (this.mediaState.isMobile) {
this.sidebarCollapsed = true;
this.saveSidebarState(true);
}
// Wait for LitElement to complete its update
await this.updateComplete;
// Trigger terminal resize after session switch to ensure proper dimensions
triggerTerminalResize(sessionId, this);
// Debug: Check what elements have view-transition-name after transition
logger.debug('after transition - elements with view-transition-name:');
document.querySelectorAll('[style*="view-transition-name"]').forEach((el) => {
@ -399,6 +455,17 @@ export class VibeTunnelApp extends LitElement {
this.selectedSessionId = sessionId;
this.currentView = 'session';
this.updateUrl(sessionId);
// Collapse sidebar on mobile after selecting a session
if (this.mediaState.isMobile) {
this.sidebarCollapsed = true;
this.saveSidebarState(true);
}
// Trigger terminal resize after session switch to ensure proper dimensions
this.updateComplete.then(() => {
triggerTerminalResize(sessionId, this);
});
}
}
@ -456,9 +523,9 @@ export class VibeTunnelApp extends LitElement {
}
// Refresh the session list after a short delay to allow animations to complete
setTimeout(() => {
window.setTimeout(() => {
this.loadSessions();
}, 500);
}, TIMING.KILL_ALL_ANIMATION_DELAY);
}
private handleCleanExited() {
@ -471,6 +538,11 @@ export class VibeTunnelApp extends LitElement {
}
}
private handleToggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed;
this.saveSidebarState(this.sidebarCollapsed);
}
// State persistence methods
private loadHideExitedState(): boolean {
try {
@ -490,6 +562,122 @@ export class VibeTunnelApp extends LitElement {
}
}
private loadSidebarState(): boolean {
try {
const saved = localStorage.getItem('sidebarCollapsed');
// Default to false (expanded) on desktop, true (collapsed) on mobile
// Use window.innerWidth for initial load since mediaState might not be initialized yet
const isMobile = window.innerWidth < BREAKPOINTS.MOBILE;
// Force expanded on desktop regardless of localStorage for better UX
const result = isMobile ? (saved !== null ? saved === 'true' : true) : false;
logger.debug('Loading sidebar state:', {
savedValue: saved,
windowWidth: window.innerWidth,
mobileBreakpoint: BREAKPOINTS.MOBILE,
isMobile,
forcedDesktopExpanded: !isMobile,
resultingState: result ? 'collapsed' : 'expanded',
});
return result;
} catch (error) {
logger.error('error loading sidebar state:', error);
return window.innerWidth < BREAKPOINTS.MOBILE; // Default based on screen size on error
}
}
private saveSidebarState(value: boolean): void {
try {
localStorage.setItem('sidebarCollapsed', String(value));
} catch (error) {
logger.error('error saving sidebar state:', error);
}
}
private loadSidebarWidth(): number {
try {
const saved = localStorage.getItem('sidebarWidth');
const width = saved !== null ? parseInt(saved, 10) : SIDEBAR.DEFAULT_WIDTH;
// Validate width is within bounds
return Math.max(SIDEBAR.MIN_WIDTH, Math.min(SIDEBAR.MAX_WIDTH, width));
} catch (error) {
logger.error('error loading sidebar width:', error);
return SIDEBAR.DEFAULT_WIDTH;
}
}
private saveSidebarWidth(value: number): void {
try {
localStorage.setItem('sidebarWidth', String(value));
} catch (error) {
logger.error('error saving sidebar width:', error);
}
}
private setupResponsiveObserver(): void {
this.responsiveUnsubscribe = responsiveObserver.subscribe((state) => {
const oldState = this.mediaState;
this.mediaState = state;
// Only trigger state changes after initial setup, not on first callback
// This prevents the sidebar from flickering on page load
if (this.responsiveObserverInitialized) {
// Auto-collapse sidebar when switching to mobile
if (!oldState.isMobile && state.isMobile && !this.sidebarCollapsed) {
this.sidebarCollapsed = true;
this.saveSidebarState(true);
}
} else {
// Mark as initialized after first callback
this.responsiveObserverInitialized = true;
}
});
}
private cleanupResizeListeners(): void {
this.resizeCleanupFunctions.forEach((cleanup) => cleanup());
this.resizeCleanupFunctions = [];
// Reset any global styles that might have been applied
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
private handleResizeStart = (e: MouseEvent) => {
e.preventDefault();
this.isResizing = true;
// Clean up any existing listeners first
this.cleanupResizeListeners();
document.addEventListener('mousemove', this.handleResize);
document.addEventListener('mouseup', this.handleResizeEnd);
// Store cleanup functions
this.resizeCleanupFunctions.push(() => {
document.removeEventListener('mousemove', this.handleResize);
document.removeEventListener('mouseup', this.handleResizeEnd);
});
document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
};
private handleResize = (e: MouseEvent) => {
if (!this.isResizing) return;
const newWidth = Math.max(SIDEBAR.MIN_WIDTH, Math.min(SIDEBAR.MAX_WIDTH, e.clientX));
this.sidebarWidth = newWidth;
this.saveSidebarWidth(newWidth);
};
private handleResizeEnd = () => {
this.isResizing = false;
this.cleanupResizeListeners();
};
// URL Routing methods
private setupRouting() {
// Handle browser back/forward navigation
@ -619,7 +807,69 @@ export class VibeTunnelApp extends LitElement {
}
};
private get showSplitView(): boolean {
return this.currentView === 'session' && this.selectedSessionId !== null;
}
private get selectedSession(): Session | undefined {
return this.sessions.find((s) => s.id === this.selectedSessionId);
}
private get sidebarClasses(): string {
if (!this.showSplitView) {
// Main view - allow normal document flow and scrolling
return 'w-full min-h-screen flex flex-col';
}
const baseClasses = 'bg-dark-bg border-r border-dark-border flex flex-col';
const isMobile = this.mediaState.isMobile;
const mobileClasses = isMobile
? 'absolute left-0 top-0 bottom-0 z-30 flex'
: 'sidebar-transition';
const collapsedClasses = this.sidebarCollapsed
? isMobile
? 'hidden mobile-sessions-sidebar collapsed'
: 'sm:w-0 sm:overflow-hidden sm:translate-x-0 flex'
: isMobile
? 'overflow-visible sm:translate-x-0 flex mobile-sessions-sidebar expanded'
: 'overflow-visible sm:translate-x-0 flex';
return `${baseClasses} ${this.showSplitView ? collapsedClasses : ''} ${this.showSplitView ? mobileClasses : ''}`;
}
private get sidebarStyles(): string {
if (!this.showSplitView || this.sidebarCollapsed) {
const isMobile = this.mediaState.isMobile;
return this.showSplitView && this.sidebarCollapsed && !isMobile ? 'width: 0px;' : '';
}
const isMobile = this.mediaState.isMobile;
if (isMobile) {
return `width: calc(100vw - ${SIDEBAR.MOBILE_RIGHT_MARGIN}px);`;
}
return `width: ${this.sidebarWidth}px;`;
}
private get shouldShowMobileOverlay(): boolean {
return this.showSplitView && !this.sidebarCollapsed && this.mediaState.isMobile;
}
private get shouldShowResizeHandle(): boolean {
return this.showSplitView && !this.sidebarCollapsed && !this.mediaState.isMobile;
}
private get mainContainerClasses(): string {
// In split view, we need strict height control and overflow hidden
// In main view, we need normal document flow for scrolling
return this.showSplitView ? 'flex h-screen overflow-hidden relative' : 'min-h-screen';
}
render() {
const showSplitView = this.showSplitView;
const selectedSession = this.selectedSession;
return html`
<!-- Error notification overlay -->
${
@ -683,49 +933,102 @@ export class VibeTunnelApp extends LitElement {
@show-ssh-key-manager=${this.handleShowSSHKeyManager}
></auth-login>
`
: this.currentView === 'session' && this.selectedSessionId
? keyed(
this.selectedSessionId,
html`
<session-view
.session=${this.sessions.find((s) => s.id === this.selectedSessionId)}
@navigate-to-list=${this.handleNavigateToList}
></session-view>
`
)
: html`
<div>
<app-header
.sessions=${this.sessions}
.hideExited=${this.hideExited}
.currentUser=${this.authClient.getCurrentUser()?.userId || null}
.authMethod=${this.authClient.getCurrentUser()?.authMethod || null}
@create-session=${this.handleCreateSession}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
@clean-exited-sessions=${this.handleCleanExited}
@open-file-browser=${this.handleOpenFileBrowser}
@open-notification-settings=${this.handleShowNotificationSettings}
@logout=${this.handleLogout}
></app-header>
<session-list
.sessions=${this.sessions}
.loading=${this.loading}
.hideExited=${this.hideExited}
.showCreateModal=${this.showCreateModal}
.authClient=${this.authClient}
@session-killed=${this.handleSessionKilled}
@session-created=${this.handleSessionCreated}
@create-modal-close=${this.handleCreateModalClose}
@refresh=${this.handleRefresh}
@error=${this.handleError}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
@navigate-to-session=${this.handleNavigateToSession}
></session-list>
: html`
<!-- Main content with split view support -->
<div class="${this.mainContainerClasses}">
<!-- Mobile overlay when sidebar is open -->
${this.shouldShowMobileOverlay
? html`
<!-- Translucent overlay over session content -->
<div
class="absolute inset-0 bg-black bg-opacity-10 sm:hidden transition-opacity"
style="left: calc(100vw - ${SIDEBAR.MOBILE_RIGHT_MARGIN}px); transition-duration: ${TRANSITIONS.MOBILE_SLIDE}ms;"
@click=${this.handleToggleSidebar}
></div>
<!-- Clickable area behind sidebar -->
<div
class="absolute inset-0 bg-black bg-opacity-50 sm:hidden transition-opacity"
style="right: ${SIDEBAR.MOBILE_RIGHT_MARGIN}px; transition-duration: ${TRANSITIONS.MOBILE_SLIDE}ms;"
@click=${this.handleToggleSidebar}
></div>
`
: ''}
<!-- Sidebar with session list - always visible on desktop -->
<div class="${this.sidebarClasses}" style="${this.sidebarStyles}">
<app-header
.sessions=${this.sessions}
.hideExited=${this.hideExited}
.showSplitView=${showSplitView}
.currentUser=${this.authClient.getCurrentUser()?.userId || null}
.authMethod=${this.authClient.getCurrentUser()?.authMethod || null}
@create-session=${this.handleCreateSession}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
@clean-exited-sessions=${this.handleCleanExited}
@open-file-browser=${this.handleOpenFileBrowser}
@open-notification-settings=${this.handleShowNotificationSettings}
@logout=${this.handleLogout}
></app-header>
<div class="${this.showSplitView ? 'flex-1 overflow-y-auto' : 'flex-1'} bg-dark-bg">
<session-list
.sessions=${this.sessions}
.loading=${this.loading}
.hideExited=${this.hideExited}
.showCreateModal=${this.showCreateModal}
.selectedSessionId=${this.selectedSessionId}
.compactMode=${showSplitView}
.authClient=${this.authClient}
@session-killed=${this.handleSessionKilled}
@session-created=${this.handleSessionCreated}
@create-modal-close=${this.handleCreateModalClose}
@refresh=${this.handleRefresh}
@error=${this.handleError}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
@navigate-to-session=${this.handleNavigateToSession}
@open-file-browser=${() => (this.showFileBrowser = true)}
></session-list>
</div>
</div>
<!-- Resize handle for sidebar -->
${this.shouldShowResizeHandle
? html`
<div
class="w-1 bg-dark-border hover:bg-accent-green cursor-ew-resize transition-colors ${this
.isResizing
? 'bg-accent-green'
: ''}"
style="transition-duration: ${TRANSITIONS.RESIZE_HANDLE}ms;"
@mousedown=${this.handleResizeStart}
title="Drag to resize sidebar"
></div>
`
: ''}
<!-- Main content area -->
${showSplitView
? html`
<div class="flex-1 relative sm:static transition-none">
${keyed(
this.selectedSessionId,
html`
<session-view
.session=${selectedSession}
.showBackButton=${false}
.showSidebarToggle=${true}
.sidebarCollapsed=${this.sidebarCollapsed}
@navigate-to-list=${this.handleNavigateToList}
@toggle-sidebar=${this.handleToggleSidebar}
></session-view>
`
)}
</div>
`
}
: ''}
</div>
`}
<!-- File Browser Modal -->
<file-browser
@ -755,10 +1058,10 @@ export class VibeTunnelApp extends LitElement {
></ssh-key-manager>
<!-- Version and logs link in bottom right -->
<div class="fixed bottom-4 right-4 text-dark-text-muted text-xs font-mono">
<div class="fixed bottom-4 right-4 text-dark-text-muted text-xs font-mono z-20">
<a href="/logs" class="hover:text-dark-text transition-colors">Logs</a>
<span class="ml-2">v${VERSION}</span>
</div>
`;
}
}
}

View file

@ -1,21 +1,23 @@
/**
* App Header Component
*
* Displays the VibeTunnel logo, session statistics, and control buttons.
* Provides controls for creating sessions, toggling exited sessions visibility,
* killing all sessions, and cleaning up exited sessions.
* Conditionally renders either a compact sidebar header or full-width header
* based on the showSplitView property.
*
* @fires create-session - When create button is clicked
* @fires hide-exited-change - When hide/show exited toggle is clicked (detail: boolean)
* @fires kill-all-sessions - When kill all button is clicked
* @fires clean-exited-sessions - When clean exited button is clicked
* @fires open-file-browser - When browse button is clicked
* @fires logout - When logout is clicked
*/
import { html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { Session } from './session-list.js';
import './terminal-icon.js';
import './notification-status.js';
import './sidebar-header.js';
import './full-header.js';
@customElement('app-header')
export class AppHeader extends LitElement {
@ -25,333 +27,55 @@ export class AppHeader extends LitElement {
@property({ type: Array }) sessions: Session[] = [];
@property({ type: Boolean }) hideExited = true;
@property({ type: Boolean }) showSplitView = false;
@property({ type: String }) currentUser: string | null = null;
@property({ type: String }) authMethod: string | null = null;
@state() private killingAll = false;
@state() private showUserMenu = false;
private handleCreateSession(e: MouseEvent) {
// Capture button position for view transition
const button = e.currentTarget as HTMLButtonElement;
const rect = button.getBoundingClientRect();
// Store position in CSS custom properties for the transition
document.documentElement.style.setProperty('--vt-button-x', `${rect.left + rect.width / 2}px`);
document.documentElement.style.setProperty('--vt-button-y', `${rect.top + rect.height / 2}px`);
document.documentElement.style.setProperty('--vt-button-width', `${rect.width}px`);
document.documentElement.style.setProperty('--vt-button-height', `${rect.height}px`);
this.dispatchEvent(new CustomEvent('create-session'));
}
private handleLogout() {
this.showUserMenu = false;
this.dispatchEvent(new CustomEvent('logout'));
}
private toggleUserMenu() {
this.showUserMenu = !this.showUserMenu;
}
private handleClickOutside = (e: Event) => {
const target = e.target as HTMLElement;
if (!target.closest('.user-menu-container')) {
this.showUserMenu = false;
}
private forwardEvent = (e: Event) => {
// Forward events from child components to parent
this.dispatchEvent(
new CustomEvent(e.type, {
detail: (e as CustomEvent).detail,
bubbles: true,
})
);
};
connectedCallback() {
super.connectedCallback();
document.addEventListener('click', this.handleClickOutside);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this.handleClickOutside);
}
private handleKillAll() {
if (this.killingAll) return;
this.killingAll = true;
this.requestUpdate();
this.dispatchEvent(new CustomEvent('kill-all-sessions'));
// Reset the state after a delay to allow for the kill operations to complete
setTimeout(() => {
this.killingAll = false;
this.requestUpdate();
}, 3000); // 3 seconds should be enough for most kill operations
}
private handleCleanExited() {
this.dispatchEvent(new CustomEvent('clean-exited-sessions'));
}
private handleOpenFileBrowser() {
this.dispatchEvent(new CustomEvent('open-file-browser'));
}
render() {
const runningSessions = this.sessions.filter((session) => session.status === 'running');
const exitedSessions = this.sessions.filter((session) => session.status === 'exited');
// Reset killing state if no more running sessions
if (this.killingAll && runningSessions.length === 0) {
this.killingAll = false;
}
return this.showSplitView ? this.renderSidebarHeader() : this.renderFullHeader();
}
private renderSidebarHeader() {
return html`
<div
class="app-header bg-dark-bg-secondary border-b border-dark-border px-6 py-3"
style="padding-top: max(0.75rem, calc(0.75rem + env(safe-area-inset-top)));"
>
<!-- Mobile layout -->
<div class="flex flex-col gap-4 sm:hidden">
<!-- Centered VibeTunnel title with stats -->
<div class="text-center flex flex-col items-center gap-2">
<a
href="/"
class="text-2xl font-bold text-accent-green flex items-center gap-3 font-mono hover:opacity-80 transition-opacity cursor-pointer group"
title="Go to home"
>
<terminal-icon size="28"></terminal-icon>
<span class="group-hover:underline">VibeTunnel</span>
</a>
<p class="text-dark-text-muted text-sm font-mono">
${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'}
${exitedSessions.length > 0 ? `${exitedSessions.length} exited` : ''}
</p>
</div>
<!-- Controls row: left buttons and right buttons -->
<div class="flex items-center justify-between">
<div class="flex gap-2">
${
exitedSessions.length > 0
? html`
<button
class="btn-secondary font-mono text-xs px-4 py-2 ${
this.hideExited
? ''
: 'bg-accent-green text-dark-bg hover:bg-accent-green-darker'
}"
@click=${() =>
this.dispatchEvent(
new CustomEvent('hide-exited-change', {
detail: !this.hideExited,
})
)}
>
${
this.hideExited
? `Show (${exitedSessions.length})`
: `Hide (${exitedSessions.length})`
}
</button>
`
: ''
}
${
!this.hideExited && exitedSessions.length > 0
? html`
<button
class="btn-ghost font-mono text-xs text-status-warning"
@click=${this.handleCleanExited}
>
Clean Exited
</button>
`
: ''
}
${
runningSessions.length > 0 && !this.killingAll
? html`
<button
class="btn-ghost font-mono text-xs text-status-error"
@click=${this.handleKillAll}
>
Kill (${runningSessions.length})
</button>
`
: ''
}
</div>
<div class="flex gap-2">
<button
class="btn-secondary font-mono text-xs px-3 py-2"
@click=${this.handleOpenFileBrowser}
title="Browse Files"
>
<span class="flex items-center gap-1">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"
/>
</svg>
Browse
</span>
</button>
<notification-status
@open-settings=${() =>
this.dispatchEvent(new CustomEvent('open-notification-settings'))}
></notification-status>
<button
class="btn-primary font-mono text-xs px-4 py-2 vt-create-button"
@click=${this.handleCreateSession}
style="view-transition-name: create-session-button"
>
Create
</button>
</div>
</div>
</div>
<!-- Desktop layout: single row -->
<div class="hidden sm:flex sm:items-center sm:justify-between">
<a
href="/"
class="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer group"
title="Go to home"
>
<terminal-icon size="32"></terminal-icon>
<div>
<h1 class="text-xl font-bold text-accent-green font-mono group-hover:underline">
VibeTunnel
</h1>
<p class="text-dark-text-muted text-sm font-mono">
${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'}
${exitedSessions.length > 0 ? `${exitedSessions.length} exited` : ''}
</p>
</div>
</a>
<div class="flex items-center gap-3">
${
exitedSessions.length > 0
? html`
<button
class="btn-secondary font-mono text-xs px-4 py-2 ${
this.hideExited
? ''
: 'bg-accent-green text-dark-bg hover:bg-accent-green-darker'
}"
@click=${() =>
this.dispatchEvent(
new CustomEvent('hide-exited-change', {
detail: !this.hideExited,
})
)}
>
${
this.hideExited
? `Show Exited (${exitedSessions.length})`
: `Hide Exited (${exitedSessions.length})`
}
</button>
`
: ''
}
<div class="flex gap-2">
${
!this.hideExited && this.sessions.filter((s) => s.status === 'exited').length > 0
? html`
<button
class="btn-ghost font-mono text-xs text-status-warning"
@click=${this.handleCleanExited}
>
Clean Exited
</button>
`
: ''
}
${
runningSessions.length > 0 && !this.killingAll
? html`
<button
class="btn-ghost font-mono text-xs text-status-error"
@click=${this.handleKillAll}
>
Kill All (${runningSessions.length})
</button>
`
: ''
}
<button
class="btn-secondary font-mono text-xs px-4 py-2"
@click=${this.handleOpenFileBrowser}
title="Browse Files (⌘O)"
>
<span class="flex items-center gap-1">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"
/>
</svg>
Browse
</span>
</button>
<notification-status
@open-settings=${() =>
this.dispatchEvent(new CustomEvent('open-notification-settings'))}
></notification-status>
<button
class="btn-primary font-mono text-xs px-4 py-2 vt-create-button"
@click=${this.handleCreateSession}
style="view-transition-name: create-session-button"
>
Create Session
</button>
${
this.currentUser
? html`
<div class="user-menu-container relative">
<button
class="btn-ghost font-mono text-xs text-dark-text flex items-center gap-1"
@click=${this.toggleUserMenu}
title="User menu"
>
<span>${this.currentUser}</span>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
class="transition-transform ${this.showUserMenu ? 'rotate-180' : ''}"
>
<path d="M5 7L1 3h8z" />
</svg>
</button>
${
this.showUserMenu
? html`
<div
class="absolute right-0 top-full mt-1 bg-dark-surface border border-dark-border rounded shadow-lg py-1 z-50 min-w-32"
>
<div
class="px-3 py-2 text-xs text-dark-text-muted border-b border-dark-border"
>
${this.authMethod || 'authenticated'}
</div>
<button
class="w-full text-left px-3 py-2 text-xs font-mono text-status-warning hover:bg-dark-bg-secondary hover:text-status-error"
@click=${this.handleLogout}
>
Logout
</button>
</div>
`
: ''
}
</div>
`
: ''
}
</div>
</div>
</div>
</div>
<sidebar-header
.sessions=${this.sessions}
.hideExited=${this.hideExited}
.currentUser=${this.currentUser}
.authMethod=${this.authMethod}
@create-session=${this.forwardEvent}
@hide-exited-change=${this.forwardEvent}
@kill-all-sessions=${this.forwardEvent}
@clean-exited-sessions=${this.forwardEvent}
@logout=${this.forwardEvent}
></sidebar-header>
`;
}
}
private renderFullHeader() {
return html`
<full-header
.sessions=${this.sessions}
.hideExited=${this.hideExited}
.currentUser=${this.currentUser}
.authMethod=${this.authMethod}
@create-session=${this.forwardEvent}
@hide-exited-change=${this.forwardEvent}
@kill-all-sessions=${this.forwardEvent}
@clean-exited-sessions=${this.forwardEvent}
@open-file-browser=${this.forwardEvent}
@open-notification-settings=${this.forwardEvent}
@logout=${this.forwardEvent}
></full-header>
`;
}
}

View file

@ -0,0 +1,190 @@
/**
* Full Header Component
*
* Full-width header for list view with horizontal layout
*/
import { html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { HeaderBase } from './header-base.js';
import type { Session } from './session-list.js';
import './terminal-icon.js';
import './notification-status.js';
@customElement('full-header')
export class FullHeader extends HeaderBase {
render() {
const runningSessions = this.runningSessions;
const exitedSessions = this.exitedSessions;
return html`
<div
class="app-header bg-dark-bg-secondary border-b border-dark-border p-6"
style="padding-top: max(1.5rem, calc(1.5rem + env(safe-area-inset-top)));"
>
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
<div class="flex items-center gap-4">
<a
href="/"
class="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer group"
title="Go to home"
>
<terminal-icon size="32"></terminal-icon>
<div>
<h1 class="text-2xl font-bold text-accent-green font-mono group-hover:underline">
VibeTunnel
</h1>
<p class="text-dark-text-muted text-sm font-mono">
${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'}
running
</p>
</div>
</a>
</div>
<div class="flex flex-col sm:flex-row gap-3 sm:items-center">
<div class="flex gap-2 items-center">
<button
class="btn-secondary font-mono text-xs px-4 py-2"
@click=${() => this.dispatchEvent(new CustomEvent('open-file-browser'))}
title="Browse Files (⌘O)"
>
<span class="flex items-center gap-1">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"
/>
</svg>
Browse
</span>
</button>
<notification-status
@open-settings=${() =>
this.dispatchEvent(new CustomEvent('open-notification-settings'))}
></notification-status>
<button
class="btn-primary font-mono text-sm px-6 py-3 vt-create-button"
@click=${this.handleCreateSession}
>
Create Session
</button>
${this.renderUserMenu()}
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
${this.renderExitedToggleButton(exitedSessions)}
${this.renderActionButtons(exitedSessions, runningSessions)}
</div>
</div>
</div>
</div>
`;
}
private renderExitedToggleButton(exitedSessions: Session[]) {
if (exitedSessions.length === 0) return '';
return html`
<button
class="relative font-mono text-xs px-4 py-2 rounded-lg border transition-all duration-200 ${this
.hideExited
? 'border-dark-border bg-dark-bg-tertiary text-dark-text hover:border-accent-green-darker'
: 'border-accent-green bg-accent-green text-dark-bg hover:bg-accent-green-darker'}"
@click=${this.handleHideExitedToggle}
>
<div class="flex items-center gap-3">
<span>Show Exited (${exitedSessions.length})</span>
<div
class="w-8 h-4 rounded-full transition-colors duration-200 ${this.hideExited
? 'bg-dark-border'
: 'bg-dark-bg'}"
>
<div
class="w-3 h-3 rounded-full transition-transform duration-200 mt-0.5 ${this.hideExited
? 'translate-x-0.5 bg-dark-text-muted'
: 'translate-x-4 bg-dark-bg'}"
></div>
</div>
</div>
</button>
`;
}
private renderActionButtons(exitedSessions: Session[], runningSessions: Session[]) {
return html`
${!this.hideExited && exitedSessions.length > 0
? html`
<button
class="btn-ghost font-mono text-xs px-4 py-2 text-status-warning"
@click=${this.handleCleanExited}
>
Clean Exited (${exitedSessions.length})
</button>
`
: ''}
${runningSessions.length > 0 && !this.killingAll
? html`
<button
class="btn-ghost font-mono text-xs px-4 py-2 text-status-error"
@click=${this.handleKillAll}
>
Kill All (${runningSessions.length})
</button>
`
: ''}
${this.killingAll
? html`
<div class="flex items-center gap-2 px-4 py-2">
<div
class="w-4 h-4 border-2 border-status-error border-t-transparent rounded-full animate-spin"
></div>
<span class="text-status-error font-mono text-xs">Killing...</span>
</div>
`
: ''}
`;
}
private renderUserMenu() {
if (!this.currentUser) return '';
return html`
<div class="user-menu-container relative">
<button
class="btn-ghost font-mono text-xs text-dark-text flex items-center gap-1"
@click=${this.toggleUserMenu}
title="User menu"
>
<span>${this.currentUser}</span>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
class="transition-transform ${this.showUserMenu ? 'rotate-180' : ''}"
>
<path d="M5 7L1 3h8z" />
</svg>
</button>
${this.showUserMenu
? html`
<div
class="absolute right-0 top-full mt-1 bg-dark-surface border border-dark-border rounded shadow-lg py-1 z-50 min-w-32"
>
<div
class="px-3 py-2 text-xs text-dark-text-muted border-b border-dark-border"
>
${this.authMethod || 'authenticated'}
</div>
<button
class="w-full text-left px-3 py-2 text-xs font-mono text-status-warning hover:bg-dark-bg-secondary hover:text-status-error"
@click=${this.handleLogout}
>
Logout
</button>
</div>
`
: ''}
</div>
`;
}
}

View file

@ -0,0 +1,94 @@
/**
* Base functionality for header components
*/
import { LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import type { Session } from './session-list.js';
import { TIMING } from '../utils/constants.js';
export abstract class HeaderBase extends LitElement {
createRenderRoot() {
return this;
}
@property({ type: Array }) sessions: Session[] = [];
@property({ type: Boolean }) hideExited = true;
@property({ type: String }) currentUser: string | null = null;
@property({ type: String }) authMethod: string | null = null;
@state() protected killingAll = false;
@state() protected showUserMenu = false;
protected get runningSessions(): Session[] {
return this.sessions.filter((session) => session.status === 'running');
}
protected get exitedSessions(): Session[] {
return this.sessions.filter((session) => session.status === 'exited');
}
protected handleCreateSession(e: MouseEvent) {
// Capture button position for view transition
const button = e.currentTarget as HTMLButtonElement;
const rect = button.getBoundingClientRect();
// Store position in CSS custom properties for the transition
document.documentElement.style.setProperty('--vt-button-x', `${rect.left + rect.width / 2}px`);
document.documentElement.style.setProperty('--vt-button-y', `${rect.top + rect.height / 2}px`);
document.documentElement.style.setProperty('--vt-button-width', `${rect.width}px`);
document.documentElement.style.setProperty('--vt-button-height', `${rect.height}px`);
this.dispatchEvent(new CustomEvent('create-session'));
}
protected handleKillAll() {
if (this.killingAll) return;
this.killingAll = true;
this.requestUpdate();
this.dispatchEvent(new CustomEvent('kill-all-sessions'));
// Reset after a delay to prevent multiple clicks
window.setTimeout(() => {
this.killingAll = false;
}, TIMING.KILL_ALL_BUTTON_DISABLE_DURATION);
}
protected handleCleanExited() {
this.dispatchEvent(new CustomEvent('clean-exited-sessions'));
}
protected handleHideExitedToggle() {
this.dispatchEvent(
new CustomEvent('hide-exited-change', {
detail: !this.hideExited,
})
);
}
protected handleLogout() {
this.showUserMenu = false;
this.dispatchEvent(new CustomEvent('logout'));
}
protected toggleUserMenu() {
this.showUserMenu = !this.showUserMenu;
}
protected handleClickOutside = (e: Event) => {
const target = e.target as HTMLElement;
if (!target.closest('.user-menu-container')) {
this.showUserMenu = false;
}
};
connectedCallback() {
super.connectedCallback();
document.addEventListener('click', this.handleClickOutside);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this.handleClickOutside);
}
}

View file

@ -42,6 +42,8 @@ export class SessionList extends LitElement {
@property({ type: Boolean }) hideExited = true;
@property({ type: Boolean }) showCreateModal = false;
@property({ type: Object }) authClient!: AuthClient;
@property({ type: String }) selectedSessionId: string | null = null;
@property({ type: Boolean }) compactMode = false;
@state() private cleaningExited = false;
private previousRunningCount = 0;
@ -145,6 +147,14 @@ export class SessionList extends LitElement {
}
}
private handleOpenFileBrowser() {
this.dispatchEvent(
new CustomEvent('open-file-browser', {
bubbles: true,
})
);
}
render() {
const filteredSessions = this.hideExited
? this.sessions.filter((session) => session.status !== 'exited')
@ -226,20 +236,126 @@ export class SessionList extends LitElement {
}
</div>
`
: html`
<div class="session-flex-responsive">
: html`
<div class="${this.compactMode ? 'space-y-2' : 'session-flex-responsive'}">
${this.compactMode
? html`
<!-- Browse Files button as special tab -->
<div
class="flex items-center gap-2 p-3 rounded-md cursor-pointer transition-all hover:bg-dark-bg-tertiary border border-dark-border bg-dark-bg-secondary"
@click=${this.handleOpenFileBrowser}
title="Browse Files (⌘O)"
>
<div class="flex-1 min-w-0">
<div class="text-sm font-mono text-accent-green truncate">
📁 Browse Files
</div>
<div class="text-xs text-dark-text-muted truncate">Open file browser</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<span class="text-dark-text-muted text-xs">O</span>
</div>
</div>
`
: ''}
${repeat(
filteredSessions,
(session) => session.id,
(session) => html`
<session-card
.session=${session}
.authClient=${this.authClient}
@session-select=${this.handleSessionSelect}
@session-killed=${this.handleSessionKilled}
@session-kill-error=${this.handleSessionKillError}
>
</session-card>
${this.compactMode
? html`
<!-- Compact list item for sidebar -->
<div
class="flex items-center gap-2 p-3 rounded-md cursor-pointer transition-all hover:bg-dark-bg-tertiary ${session.id ===
this.selectedSessionId
? 'bg-dark-bg-tertiary border border-accent-green shadow-sm'
: 'border border-transparent'}"
@click=${() =>
this.handleSessionSelect({ detail: session } as CustomEvent)}
>
<div class="flex-1 min-w-0">
<div
class="text-sm font-mono text-accent-green truncate"
title="${session.name || session.command}"
>
${session.name || session.command}
</div>
<div class="text-xs text-dark-text-muted truncate">
${session.workingDir}
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<span
class="${session.status === 'running'
? 'text-status-success'
: 'text-status-warning'} text-xs flex items-center gap-1"
>
<div
class="w-2 h-2 rounded-full ${session.status === 'running'
? 'bg-status-success'
: 'bg-status-warning'}"
></div>
${session.status}
</span>
${session.status === 'running' || session.status === 'exited'
? html`
<button
class="btn-ghost text-status-error p-1 rounded hover:bg-dark-bg"
@click=${async (e: Event) => {
e.stopPropagation();
// Kill the session
try {
const endpoint =
session.status === 'exited'
? `/api/sessions/${session.id}/cleanup`
: `/api/sessions/${session.id}`;
const response = await fetch(endpoint, {
method: 'DELETE',
headers: this.authClient.getAuthHeader(),
});
if (response.ok) {
this.handleSessionKilled({
detail: { sessionId: session.id },
} as CustomEvent);
}
} catch (error) {
logger.error('Failed to kill session', error);
}
}}
title="${session.status === 'running'
? 'Kill session'
: 'Clean up session'}"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
`
: ''}
</div>
</div>
`
: html`
<!-- Full session card for main view -->
<session-card
.session=${session}
.authClient=${this.authClient}
@session-select=${this.handleSessionSelect}
@session-killed=${this.handleSessionKilled}
@session-kill-error=${this.handleSessionKillError}
>
</session-card>
`}
`
)}
</div>
@ -258,4 +374,4 @@ export class SessionList extends LitElement {
</div>
`;
}
}
}

View file

@ -38,6 +38,9 @@ export class SessionView extends LitElement {
}
@property({ type: Object }) session: Session | null = null;
@property({ type: Boolean }) showBackButton = true;
@property({ type: Boolean }) showSidebarToggle = false;
@property({ type: Boolean }) sidebarCollapsed = false;
@state() private connected = false;
@state() private terminal: Terminal | null = null;
@state() private streamConnection: {
@ -576,6 +579,16 @@ export class SessionView extends LitElement {
);
}
private handleSidebarToggle() {
// Dispatch event to toggle sidebar
this.dispatchEvent(
new CustomEvent('toggle-sidebar', {
bubbles: true,
composed: true,
})
);
}
private handleSessionExit(e: Event) {
const customEvent = e as CustomEvent;
logger.log('session exit event received', customEvent.detail);
@ -1150,7 +1163,7 @@ export class SessionView extends LitElement {
}
</style>
<div
class="flex flex-col bg-black font-mono"
class="flex flex-col bg-black font-mono relative"
style="height: 100vh; height: 100dvh; outline: none !important; box-shadow: none !important;"
>
<!-- Compact Header -->
@ -1159,13 +1172,43 @@ export class SessionView extends LitElement {
style="padding-top: max(0.5rem, env(safe-area-inset-top)); padding-left: max(0.75rem, env(safe-area-inset-left)); padding-right: max(0.75rem, env(safe-area-inset-right));"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<button
class="btn-secondary font-mono text-xs px-3 py-1 flex-shrink-0"
@click=${this.handleBack}
>
Back
</button>
<div class="text-dark-text min-w-0 flex-1 overflow-hidden">
<!-- Mobile Hamburger Menu Button (only on phones, only when session is shown) -->
${this.showSidebarToggle && this.sidebarCollapsed
? html`
<button
class="sm:hidden bg-dark-bg-tertiary border border-dark-border rounded-lg p-1 font-mono text-accent-green transition-all duration-300 hover:bg-dark-bg hover:border-accent-green flex-shrink-0"
@click=${this.handleSidebarToggle}
title="Show sessions"
>
<!-- Hamburger menu icon -->
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
`
: ''}
${this.showBackButton
? html`
<button
class="btn-secondary font-mono text-xs px-3 py-1 flex-shrink-0"
@click=${this.handleBack}
>
Back
</button>
`
: ''}
<div class="text-dark-text min-w-0 flex-1 overflow-hidden max-w-[50vw] sm:max-w-none">
<div
class="text-accent-green text-xs sm:text-sm overflow-hidden text-ellipsis whitespace-nowrap"
title="${
@ -1354,7 +1397,7 @@ export class SessionView extends LitElement {
this.session?.status === 'exited'
? html`
<div
class="absolute inset-0 flex items-center justify-center pointer-events-none z-50"
class="fixed inset-0 flex items-center justify-center pointer-events-none z-[100]"
>
<div
class="bg-dark-bg-secondary border border-dark-border ${this.getStatusColor()} font-medium text-sm tracking-wide px-4 py-2 rounded-lg shadow-lg"

View file

@ -0,0 +1,135 @@
/**
* Sidebar Header Component
*
* Compact header for sidebar/split view with vertical layout
*/
import { html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { HeaderBase } from './header-base.js';
import type { Session } from './session-list.js';
import './terminal-icon.js';
@customElement('sidebar-header')
export class SidebarHeader extends HeaderBase {
render() {
const runningSessions = this.runningSessions;
const exitedSessions = this.exitedSessions;
return html`
<div
class="app-header sidebar-header bg-dark-bg-secondary border-b border-dark-border p-3"
style="padding-top: max(0.75rem, calc(0.75rem + env(safe-area-inset-top)));"
>
<!-- Compact vertical layout for sidebar -->
<div class="flex flex-col gap-3">
<!-- Title and logo -->
<a
href="/"
class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer group"
title="Go to home"
>
<terminal-icon size="20"></terminal-icon>
<div class="min-w-0">
<h1
class="text-sm font-bold text-accent-green font-mono group-hover:underline truncate"
>
VibeTunnel
</h1>
<p class="text-dark-text-muted text-xs font-mono">
${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'}
</p>
</div>
</a>
<!-- Compact action buttons -->
<div class="flex flex-col gap-2 items-center">
<button
class="btn-primary font-mono text-xs px-3 py-1.5 vt-create-button text-center max-w-[200px] w-full"
@click=${this.handleCreateSession}
>
Create Session
</button>
<div class="flex flex-col gap-1 w-full max-w-[200px]">
${this.renderExitedToggleButton(exitedSessions, true)}
${this.renderActionButtons(exitedSessions, runningSessions, true)}
</div>
</div>
</div>
</div>
`;
}
private renderExitedToggleButton(exitedSessions: Session[], compact: boolean) {
if (exitedSessions.length === 0) return '';
const buttonClass = compact
? 'relative font-mono text-xs px-3 py-1.5 w-full rounded-lg border transition-all duration-200'
: 'relative font-mono text-xs px-4 py-2 rounded-lg border transition-all duration-200';
const stateClass = this.hideExited
? 'border-dark-border bg-dark-bg-tertiary text-dark-text hover:border-accent-green-darker'
: 'border-accent-green bg-accent-green text-dark-bg hover:bg-accent-green-darker';
return html`
<button
class="${buttonClass} ${stateClass}"
@click=${this.handleHideExitedToggle}
title="${this.hideExited
? `Show ${exitedSessions.length} exited sessions`
: `Hide ${exitedSessions.length} exited sessions`}"
>
<div class="flex items-center ${compact ? 'justify-between' : 'gap-2'}">
<span>${compact ? 'Show Exited' : `Show Exited (${exitedSessions.length})`}</span>
<div class="flex items-center gap-2">
${compact
? html`<span class="text-xs opacity-75">(${exitedSessions.length})</span>`
: ''}
<div
class="w-${compact ? '8' : '6'} h-${compact
? '4'
: '3'} rounded-full transition-colors duration-200 ${this.hideExited
? 'bg-dark-border'
: 'bg-dark-bg'}"
>
<div
class="w-${compact ? '3' : '2'} h-${compact
? '3'
: '2'} rounded-full transition-transform duration-200 mt-0.5 ${this.hideExited
? `translate-x-0.5 bg-dark-text-muted`
: `translate-x-${compact ? '4' : '3'} bg-dark-bg`}"
></div>
</div>
</div>
</div>
</button>
`;
}
private renderActionButtons(
exitedSessions: Session[],
runningSessions: Session[],
compact: boolean
) {
const buttonClass = compact
? 'btn-ghost font-mono text-xs px-3 py-1.5 w-full'
: 'btn-ghost font-mono text-xs px-4 py-2';
return html`
${!this.hideExited && exitedSessions.length > 0
? html`
<button class="${buttonClass} text-status-warning" @click=${this.handleCleanExited}>
Clean Exited (${exitedSessions.length})
</button>
`
: ''}
${runningSessions.length > 0 && !this.killingAll
? html`
<button class="${buttonClass} text-status-error" @click=${this.handleKillAll}>
Kill (${runningSessions.length})
</button>
`
: ''}
`;
}
}

View file

@ -200,7 +200,7 @@ export class VibeTerminalBuffer extends LitElement {
</style>
<div
class="relative w-full h-full overflow-hidden bg-black"
style="view-transition-name: terminal-${this.sessionId}"
style="view-transition-name: terminal-${this.sessionId}; min-height: 200px;"
>
${
this.error
@ -245,6 +245,14 @@ export class VibeTerminalBuffer extends LitElement {
html += `<div class="terminal-line" style="height: ${lineHeight}px; line-height: ${lineHeight}px;">${lineContent}</div>`;
}
// If no content, add empty lines to maintain consistent height
if (html === '' || this.buffer.cells.length === 0) {
// Add a few empty lines to ensure the terminal has some height
for (let i = 0; i < Math.max(3, this.visibleRows); i++) {
html += `<div class="terminal-line" style="height: ${lineHeight}px; line-height: ${lineHeight}px;">&nbsp;</div>`;
}
}
// Set innerHTML directly like terminal.ts does
this.container.innerHTML = html;
}

View file

@ -2,6 +2,35 @@
@tailwind components;
@tailwind utilities;
/* CSS Custom Properties for VibeTunnel constants */
:root {
/* Breakpoints */
--vt-breakpoint-mobile: 768px;
--vt-breakpoint-tablet: 1024px;
--vt-breakpoint-desktop: 1280px;
/* Sidebar dimensions */
--vt-sidebar-default-width: 320px;
--vt-sidebar-min-width: 240px;
--vt-sidebar-max-width: 600px;
--vt-sidebar-mobile-right-margin: 80px;
/* Transitions */
--vt-transition-sidebar: 200ms;
--vt-transition-mobile-slide: 200ms;
--vt-transition-resize-handle: 200ms;
/* Z-index layers */
--vt-z-mobile-overlay: 20;
--vt-z-sidebar-mobile: 30;
--vt-z-session-exited-overlay: 100;
/* Terminal */
--vt-terminal-min-height: 200px;
--vt-terminal-default-visible-rows: 24;
--vt-terminal-resize-debounce: 100ms;
}
/* Global dark theme styles */
@layer base {
body {
@ -1000,6 +1029,46 @@ body {
}
}
/* Split view sidebar animations */
.sidebar-transition {
transition: width var(--vt-transition-sidebar) cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Mobile sessions list slide animation */
@media (max-width: 768px) {
.mobile-sessions-sidebar {
transition: transform var(--vt-transition-mobile-slide) cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.mobile-sessions-sidebar.collapsed {
transform: translateX(-100%);
}
.mobile-sessions-sidebar.expanded {
transform: translateX(0);
}
}
/* Ensure proper scrolling in split view */
.split-view-sidebar {
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
}
/* Responsive breakpoints for split view */
@media (max-width: 768px) {
/* On mobile, sidebar should take most of the width when expanded, leaving 80px for tap-to-close */
.split-view-sidebar-expanded {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: calc(100vw - 80px) !important;
z-index: 30;
}
}
/* Phosphor Terminal Decay effect for exited sessions */
.session-exited {
filter: sepia(0.3) hue-rotate(45deg) brightness(0.8) contrast(1.2);
@ -1041,3 +1110,74 @@ body {
pointer-events: none;
opacity: 0.5;
}
/* View transition animations for split view */
@view-transition {
navigation: auto;
}
/* Fade transition for header elements during view transitions */
::view-transition-old(app-header),
::view-transition-new(app-header) {
animation-duration: 0.3s;
animation-timing-function: ease-in-out;
}
::view-transition-old(app-header) {
animation-name: fade-out;
}
::view-transition-new(app-header) {
animation-name: fade-in;
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* Disable morphing animations for sidebar elements */
.sidebar-header {
view-transition-name: sidebar-header;
}
::view-transition-old(sidebar-header),
::view-transition-new(sidebar-header) {
animation-duration: 0s !important;
}
/* Prevent header flicker during session transitions */
.app-header {
view-transition-name: none !important;
}
@keyframes fade-out-fast {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fade-in-fast {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View file

@ -0,0 +1,41 @@
// UI Constants for VibeTunnel
export const BREAKPOINTS = {
MOBILE: 768,
TABLET: 1024,
DESKTOP: 1280,
} as const;
export const SIDEBAR = {
DEFAULT_WIDTH: 320,
MIN_WIDTH: 240,
MAX_WIDTH: 600,
MOBILE_RIGHT_MARGIN: 80,
} as const;
export const TRANSITIONS = {
SIDEBAR: 200,
MOBILE_SLIDE: 200,
RESIZE_HANDLE: 200,
} as const;
export const Z_INDEX = {
MOBILE_OVERLAY: 20,
SIDEBAR_MOBILE: 30,
SESSION_EXITED_OVERLAY: 100,
} as const;
export const TERMINAL = {
MIN_HEIGHT: 200,
DEFAULT_VISIBLE_ROWS: 24,
RESIZE_DEBOUNCE: 100,
} as const;
export const TIMING = {
AUTO_REFRESH_INTERVAL: 3000,
SESSION_SEARCH_DELAY: 500,
KILL_ALL_ANIMATION_DELAY: 500,
ERROR_MESSAGE_TIMEOUT: 5000,
SUCCESS_MESSAGE_TIMEOUT: 5000,
KILL_ALL_BUTTON_DISABLE_DURATION: 2000,
} as const;

View file

@ -0,0 +1,104 @@
import { BREAKPOINTS } from './constants.js';
export interface MediaQueryState {
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
}
/**
* Creates a responsive utility that uses ResizeObserver for efficient viewport tracking
*/
export class ResponsiveObserver {
private callbacks = new Set<(state: MediaQueryState) => void>();
private currentState: MediaQueryState;
private resizeObserver: ResizeObserver | null = null;
constructor() {
this.currentState = this.getMediaQueryState();
try {
// Use ResizeObserver on document.documentElement for efficient viewport tracking
this.resizeObserver = new ResizeObserver(() => {
try {
const newState = this.getMediaQueryState();
if (this.hasStateChanged(this.currentState, newState)) {
this.currentState = newState;
this.notifyCallbacks(newState);
}
} catch (error) {
console.error('Error in ResizeObserver callback:', error);
}
});
this.resizeObserver.observe(document.documentElement);
} catch (error) {
console.error('Failed to initialize ResizeObserver:', error);
// Fallback to window resize events
this.setupFallbackResizeListener();
}
}
private setupFallbackResizeListener(): void {
let timeoutId: number;
const handleResize = () => {
clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
const newState = this.getMediaQueryState();
if (this.hasStateChanged(this.currentState, newState)) {
this.currentState = newState;
this.notifyCallbacks(newState);
}
}, 100);
};
window.addEventListener('resize', handleResize);
}
private getMediaQueryState(): MediaQueryState {
const width = window.innerWidth;
return {
isMobile: width < BREAKPOINTS.MOBILE,
isTablet: width >= BREAKPOINTS.MOBILE && width < BREAKPOINTS.DESKTOP,
isDesktop: width >= BREAKPOINTS.DESKTOP,
};
}
private hasStateChanged(oldState: MediaQueryState, newState: MediaQueryState): boolean {
return (
oldState.isMobile !== newState.isMobile ||
oldState.isTablet !== newState.isTablet ||
oldState.isDesktop !== newState.isDesktop
);
}
private notifyCallbacks(state: MediaQueryState): void {
this.callbacks.forEach((callback) => callback(state));
}
subscribe(callback: (state: MediaQueryState) => void): () => void {
this.callbacks.add(callback);
// Immediately call with current state
callback(this.currentState);
// Return unsubscribe function
return () => {
this.callbacks.delete(callback);
};
}
getCurrentState(): MediaQueryState {
return { ...this.currentState };
}
destroy(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.callbacks.clear();
}
}
// Singleton instance for global use
export const responsiveObserver = new ResponsiveObserver();

View file

@ -0,0 +1,43 @@
import { createLogger } from './logger.js';
const logger = createLogger('terminal-utils');
export interface TerminalElement extends HTMLElement {
fitTerminal?: () => void;
}
/**
* Triggers a terminal resize event for proper dimensions
* @param sessionId - The session ID for logging purposes
* @param container - Optional container to search within
*/
export function triggerTerminalResize(sessionId: string, container?: HTMLElement): void {
requestAnimationFrame(() => {
const searchRoot = container || document;
const terminal = searchRoot.querySelector('vibe-terminal') as TerminalElement;
if (terminal?.fitTerminal) {
logger.debug(`triggering terminal resize for session ${sessionId}`);
terminal.fitTerminal();
} else {
logger.warn(`terminal not found or fitTerminal method unavailable for session ${sessionId}`);
}
});
}
/**
* Debounced version of terminal resize trigger
*/
export function createDebouncedTerminalResize(delay = 100) {
let timeoutId: number | undefined;
return (sessionId: string, container?: HTMLElement) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
triggerTerminalResize(sessionId, container);
}, delay);
};
}