From bc370452ad7c1dafe7873463951fc31accdfd466 Mon Sep 17 00:00:00 2001 From: Manuel Maly Date: Mon, 23 Jun 2025 16:12:06 +0200 Subject: [PATCH] feat(web): implement vertical tabs with Arc-style persistent sidebar and comprehensive mobile UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/client/app.ts | 411 +++++++++++++++--- web/src/client/components/app-header.ts | 374 +++------------- web/src/client/components/full-header.ts | 190 ++++++++ web/src/client/components/header-base.ts | 94 ++++ web/src/client/components/session-list.ts | 138 +++++- web/src/client/components/session-view.ts | 61 ++- web/src/client/components/sidebar-header.ts | 135 ++++++ .../client/components/vibe-terminal-buffer.ts | 10 +- web/src/client/styles.css | 140 ++++++ web/src/client/utils/constants.ts | 41 ++ web/src/client/utils/responsive-utils.ts | 104 +++++ web/src/client/utils/terminal-utils.ts | 43 ++ 12 files changed, 1341 insertions(+), 400 deletions(-) create mode 100644 web/src/client/components/full-header.ts create mode 100644 web/src/client/components/header-base.ts create mode 100644 web/src/client/components/sidebar-header.ts create mode 100644 web/src/client/utils/constants.ts create mode 100644 web/src/client/utils/responsive-utils.ts create mode 100644 web/src/client/utils/terminal-utils.ts diff --git a/web/src/client/app.ts b/web/src/client/app.ts index 5537105c..0cc6e748 100644 --- a/web/src/client/app.ts +++ b/web/src/client/app.ts @@ -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` ${ @@ -683,49 +933,102 @@ export class VibeTunnelApp extends LitElement { @show-ssh-key-manager=${this.handleShowSSHKeyManager} > ` - : this.currentView === 'session' && this.selectedSessionId - ? keyed( - this.selectedSessionId, - html` - s.id === this.selectedSessionId)} - @navigate-to-list=${this.handleNavigateToList} - > - ` - ) - : html` -
- - + : html` + +
+ + ${this.shouldShowMobileOverlay + ? html` + +
+ +
+ ` + : ''} + + +
+ +
+ (this.showFileBrowser = true)} + > +
+
+ + + ${this.shouldShowResizeHandle + ? html` +
+ ` + : ''} + + + ${showSplitView + ? html` +
+ ${keyed( + this.selectedSessionId, + html` + + ` + )}
` - } + : ''} +
+ `} -
+
Logs v${VERSION}
`; } -} +} \ No newline at end of file diff --git a/web/src/client/components/app-header.ts b/web/src/client/components/app-header.ts index bc3edae7..0a08f0b4 100644 --- a/web/src/client/components/app-header.ts +++ b/web/src/client/components/app-header.ts @@ -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` -
- -
- -
- - - VibeTunnel - -

- ${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'} - ${exitedSessions.length > 0 ? `• ${exitedSessions.length} exited` : ''} -

-
- - -
-
- ${ - exitedSessions.length > 0 - ? html` - - ` - : '' - } - ${ - !this.hideExited && exitedSessions.length > 0 - ? html` - - ` - : '' - } - ${ - runningSessions.length > 0 && !this.killingAll - ? html` - - ` - : '' - } -
- -
- - - this.dispatchEvent(new CustomEvent('open-notification-settings'))} - > - -
-
-
- - - -
+ `; } -} + + private renderFullHeader() { + return html` + + `; + } +} \ No newline at end of file diff --git a/web/src/client/components/full-header.ts b/web/src/client/components/full-header.ts new file mode 100644 index 00000000..e9821dd0 --- /dev/null +++ b/web/src/client/components/full-header.ts @@ -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` +
+
+ + +
+
+ + + this.dispatchEvent(new CustomEvent('open-notification-settings'))} + > + + ${this.renderUserMenu()} +
+ +
+ ${this.renderExitedToggleButton(exitedSessions)} + ${this.renderActionButtons(exitedSessions, runningSessions)} +
+
+
+
+ `; + } + + private renderExitedToggleButton(exitedSessions: Session[]) { + if (exitedSessions.length === 0) return ''; + + return html` + + `; + } + + private renderActionButtons(exitedSessions: Session[], runningSessions: Session[]) { + return html` + ${!this.hideExited && exitedSessions.length > 0 + ? html` + + ` + : ''} + ${runningSessions.length > 0 && !this.killingAll + ? html` + + ` + : ''} + ${this.killingAll + ? html` +
+
+ Killing... +
+ ` + : ''} + `; + } + + private renderUserMenu() { + if (!this.currentUser) return ''; + + return html` +
+ + ${this.showUserMenu + ? html` +
+
+ ${this.authMethod || 'authenticated'} +
+ +
+ ` + : ''} +
+ `; + } +} diff --git a/web/src/client/components/header-base.ts b/web/src/client/components/header-base.ts new file mode 100644 index 00000000..85059c95 --- /dev/null +++ b/web/src/client/components/header-base.ts @@ -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); + } +} diff --git a/web/src/client/components/session-list.ts b/web/src/client/components/session-list.ts index bd1e9bcb..e82bf27c 100644 --- a/web/src/client/components/session-list.ts +++ b/web/src/client/components/session-list.ts @@ -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 { }
` - : html` -
+ : html` +
+ ${this.compactMode + ? html` + +
+
+
+ 📁 Browse Files +
+
Open file browser
+
+
+ ⌘O +
+
+ ` + : ''} ${repeat( filteredSessions, (session) => session.id, (session) => html` - - + ${this.compactMode + ? html` + +
+ this.handleSessionSelect({ detail: session } as CustomEvent)} + > +
+
+ ${session.name || session.command} +
+
+ ${session.workingDir} +
+
+
+ +
+ ${session.status} +
+ ${session.status === 'running' || session.status === 'exited' + ? html` + + ` + : ''} +
+
+ ` + : html` + + + + `} ` )}
@@ -258,4 +374,4 @@ export class SessionList extends LitElement {
`; } -} +} \ No newline at end of file diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index 9199272c..c7e3dc18 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -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 { }
@@ -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));" >
- -
+ + ${this.showSidebarToggle && this.sidebarCollapsed + ? html` + + ` + : ''} + ${this.showBackButton + ? html` + + ` + : ''} +
+ +
+ + + +
+

+ VibeTunnel +

+

+ ${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'} +

+
+
+ + +
+ + +
+ ${this.renderExitedToggleButton(exitedSessions, true)} + ${this.renderActionButtons(exitedSessions, runningSessions, true)} +
+
+
+
+ `; + } + + 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` + + `; + } + + 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` + + ` + : ''} + ${runningSessions.length > 0 && !this.killingAll + ? html` + + ` + : ''} + `; + } +} diff --git a/web/src/client/components/vibe-terminal-buffer.ts b/web/src/client/components/vibe-terminal-buffer.ts index af822052..1564e8c7 100644 --- a/web/src/client/components/vibe-terminal-buffer.ts +++ b/web/src/client/components/vibe-terminal-buffer.ts @@ -200,7 +200,7 @@ export class VibeTerminalBuffer extends LitElement {
${ this.error @@ -245,6 +245,14 @@ export class VibeTerminalBuffer extends LitElement { html += `
${lineContent}
`; } + // 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 += `
 
`; + } + } + // Set innerHTML directly like terminal.ts does this.container.innerHTML = html; } diff --git a/web/src/client/styles.css b/web/src/client/styles.css index 5f28a0c7..84c7177b 100644 --- a/web/src/client/styles.css +++ b/web/src/client/styles.css @@ -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; + } +} diff --git a/web/src/client/utils/constants.ts b/web/src/client/utils/constants.ts new file mode 100644 index 00000000..11863e21 --- /dev/null +++ b/web/src/client/utils/constants.ts @@ -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; diff --git a/web/src/client/utils/responsive-utils.ts b/web/src/client/utils/responsive-utils.ts new file mode 100644 index 00000000..45f014de --- /dev/null +++ b/web/src/client/utils/responsive-utils.ts @@ -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(); diff --git a/web/src/client/utils/terminal-utils.ts b/web/src/client/utils/terminal-utils.ts new file mode 100644 index 00000000..71e825ba --- /dev/null +++ b/web/src/client/utils/terminal-utils.ts @@ -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); + }; +}