From acf91e228da049fa5787526a2b2ad0642effea6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 1 Jul 2025 14:52:56 +0100 Subject: [PATCH] Add sidebar toggle button with collapse/expand functionality (#175) --- web/src/client/app.ts | 104 ++++++++---------- web/src/client/components/app-header.ts | 2 + web/src/client/components/session-list.ts | 69 ++++++++---- web/src/client/components/session-view.ts | 11 ++ .../components/session-view/session-header.ts | 52 +++++---- web/src/client/components/sidebar-header.ts | 22 +++- web/src/shared/utils/time.ts | 36 ++++++ 7 files changed, 187 insertions(+), 109 deletions(-) create mode 100644 web/src/shared/utils/time.ts diff --git a/web/src/client/app.ts b/web/src/client/app.ts index 508fd12e..769dd858 100644 --- a/web/src/client/app.ts +++ b/web/src/client/app.ts @@ -62,7 +62,6 @@ export class VibeTunnelApp extends LitElement { @state() private isAuthenticated = false; @state() private sidebarCollapsed = this.loadSidebarState(); @state() private sidebarWidth = this.loadSidebarWidth(); - @state() private userInitiatedSessionChange = false; @state() private isResizing = false; @state() private mediaState: MediaQueryState = responsiveObserver.getCurrentState(); @state() private showLogLink = false; @@ -70,6 +69,7 @@ export class VibeTunnelApp extends LitElement { private initialLoadComplete = false; private responsiveObserverInitialized = false; private initialRenderComplete = false; + private sidebarAnimationReady = false; private hotReloadWs: WebSocket | null = null; private errorTimeoutId: number | null = null; @@ -95,6 +95,10 @@ export class VibeTunnelApp extends LitElement { // Mark initial render as complete after a microtask to ensure DOM is settled Promise.resolve().then(() => { this.initialRenderComplete = true; + // Enable sidebar animations after a short delay to prevent initial load animations + setTimeout(() => { + this.sidebarAnimationReady = true; + }, 100); }); } @@ -140,6 +144,12 @@ export class VibeTunnelApp extends LitElement { this.showFileBrowser = true; } + // Handle Cmd+B / Ctrl+B to toggle sidebar + if ((e.metaKey || e.ctrlKey) && e.key === 'b') { + e.preventDefault(); + this.handleToggleSidebar(); + } + // Handle Escape to close the session and return to list view if ( e.key === 'Escape' && @@ -213,18 +223,10 @@ export class VibeTunnelApp extends LitElement { const url = new URL(window.location.href); const sessionId = url.searchParams.get('session'); if (sessionId) { - // Try to find the session and navigate to it - const session = this.sessions.find((s) => s.id === sessionId); - if (session) { - this.userInitiatedSessionChange = false; - this.selectedSessionId = sessionId; - this.currentView = 'session'; - - // Update page title with session name - const sessionName = session.name || session.command.join(' '); - console.log('[App] Setting title from checkUrlParams:', sessionName); - document.title = `${sessionName} - VibeTunnel`; - } + // Always navigate to the session view if a session ID is provided + logger.log(`Navigating to session ${sessionId} from URL after auth`); + this.selectedSessionId = sessionId; + this.currentView = 'session'; } } @@ -350,20 +352,14 @@ export class VibeTunnelApp extends LitElement { document.title = `VibeTunnel - ${sessionCount} Session${sessionCount !== 1 ? 's' : ''}`; } - // Check if currently selected session still exists after refresh + // Don't redirect away from session view during loadSessions + // The session-view component will handle missing sessions if (this.selectedSessionId && this.currentView === 'session') { const sessionExists = this.sessions.find((s) => s.id === this.selectedSessionId); if (!sessionExists) { - // Session no longer exists, redirect to dashboard logger.warn( - `Selected session ${this.selectedSessionId} no longer exists, redirecting to dashboard` + `Selected session ${this.selectedSessionId} not found in current sessions list, but keeping session view` ); - this.selectedSessionId = null; - this.currentView = 'list'; - // Clear the session param from URL - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete('session'); - window.history.replaceState({}, '', newUrl.toString()); } } } else if (response.status === 401) { @@ -637,8 +633,6 @@ export class VibeTunnelApp extends LitElement { mediaStateIsMobile: this.mediaState.isMobile, }); - this.userInitiatedSessionChange = true; - // 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 @@ -837,19 +831,17 @@ 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; + // Respect saved state if it exists, otherwise default based on device type + const result = saved !== null ? saved === 'true' : isMobile; logger.debug('Loading sidebar state:', { savedValue: saved, windowWidth: window.innerWidth, mobileBreakpoint: BREAKPOINTS.MOBILE, isMobile, - forcedDesktopExpanded: !isMobile, + hasSavedState: saved !== null, resultingState: result ? 'collapsed' : 'expanded', }); @@ -994,26 +986,17 @@ export class VibeTunnelApp extends LitElement { } if (sessionId) { - // Check if we have sessions loaded - if (this.sessions.length === 0 && this.isAuthenticated) { - // Sessions not loaded yet, load them first - await this.loadSessions(); - } + // Always navigate to the session view if a session ID is provided + // The session-view component will handle loading and error cases + logger.log(`Navigating to session ${sessionId} from URL`); + this.selectedSessionId = sessionId; + this.currentView = 'session'; - // Now check if the session exists - const session = this.sessions.find((s) => s.id === sessionId); - if (session) { - this.selectedSessionId = sessionId; - this.currentView = 'session'; - } else { - // Session not found, go to list view - logger.warn(`Session ${sessionId} not found in sessions list`); - this.selectedSessionId = null; - this.currentView = 'list'; - // Clear the session param from URL - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete('session'); - window.history.replaceState({}, '', newUrl.toString()); + // Load sessions in the background if not already loaded + if (this.sessions.length === 0 && this.isAuthenticated) { + this.loadSessions().catch((error) => { + logger.error('Error loading sessions:', error); + }); } } else { this.selectedSessionId = null; @@ -1114,18 +1097,14 @@ export class VibeTunnelApp extends LitElement { const baseClasses = 'bg-dark-bg-secondary border-r border-dark-border flex flex-col'; const isMobile = this.mediaState.isMobile; - const transitionClass = - this.initialRenderComplete && !isMobile - ? this.userInitiatedSessionChange - ? 'sidebar-transition' - : '' - : ''; + // Only apply transition class when animations are ready (not during initial load) + const transitionClass = this.sidebarAnimationReady && !isMobile ? 'sidebar-transition' : ''; const mobileClasses = isMobile ? 'absolute left-0 top-0 bottom-0 z-30 flex' : transitionClass; const collapsedClasses = this.sidebarCollapsed ? isMobile ? 'hidden mobile-sessions-sidebar collapsed' - : 'sm:w-0 sm:overflow-hidden sm:translate-x-0 flex' + : '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'; @@ -1134,12 +1113,18 @@ export class VibeTunnelApp extends LitElement { } private get sidebarStyles(): string { - if (!this.showSplitView || this.sidebarCollapsed) { - const isMobile = this.mediaState.isMobile; - return this.showSplitView && this.sidebarCollapsed && !isMobile ? 'width: 0px;' : ''; + if (!this.showSplitView) { + return ''; } const isMobile = this.mediaState.isMobile; + + if (this.sidebarCollapsed) { + // Hide completely on both desktop and mobile + return 'width: 0px;'; + } + + // Expanded state if (isMobile) { return `width: calc(100vw - ${SIDEBAR.MOBILE_RIGHT_MARGIN}px);`; } @@ -1282,6 +1267,7 @@ export class VibeTunnelApp extends LitElement { @open-settings=${this.handleOpenSettings} @logout=${this.handleLogout} @navigate-to-list=${this.handleNavigateToList} + @toggle-sidebar=${this.handleToggleSidebar} >
` diff --git a/web/src/client/components/app-header.ts b/web/src/client/components/app-header.ts index 489245a0..2710897b 100644 --- a/web/src/client/components/app-header.ts +++ b/web/src/client/components/app-header.ts @@ -10,6 +10,7 @@ * @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 + * @fires toggle-sidebar - When sidebar toggle button is clicked */ import { html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @@ -60,6 +61,7 @@ export class AppHeader extends LitElement { @open-settings=${this.forwardEvent} @logout=${this.forwardEvent} @navigate-to-list=${this.forwardEvent} + @toggle-sidebar=${this.forwardEvent} > `; } diff --git a/web/src/client/components/session-list.ts b/web/src/client/components/session-list.ts index de7ca22c..ba3a9f4a 100644 --- a/web/src/client/components/session-list.ts +++ b/web/src/client/components/session-list.ts @@ -22,6 +22,7 @@ import { repeat } from 'lit/directives/repeat.js'; import type { Session } from '../../shared/types.js'; import type { AuthClient } from '../services/auth-client.js'; import './session-card.js'; +import { formatSessionDuration } from '../../shared/utils/time.js'; import { createLogger } from '../utils/logger.js'; import { formatPathForDisplay } from '../utils/path-utils.js'; @@ -238,14 +239,40 @@ export class SessionList extends LitElement { ? html`
this.handleSessionSelect({ detail: session } as CustomEvent)} > + +
+ + +
+ +
-
-
+ + +
+ +
+ ${session.startedAt ? formatSessionDuration(session.startedAt) : ''} +
+ + ${ session.status === 'running' || session.status === 'exited' ? html` + + + + + + + + +
` : '' } diff --git a/web/src/client/components/sidebar-header.ts b/web/src/client/components/sidebar-header.ts index 25b7fdb7..c96157f8 100644 --- a/web/src/client/components/sidebar-header.ts +++ b/web/src/client/components/sidebar-header.ts @@ -20,10 +20,24 @@ export class SidebarHeader extends HeaderBase { style="padding-top: max(0.75rem, calc(0.75rem + env(safe-area-inset-top)));" > -
- +
+ + + + -
+
this.dispatchEvent(new CustomEvent('open-settings'))} diff --git a/web/src/shared/utils/time.ts b/web/src/shared/utils/time.ts new file mode 100644 index 00000000..2b6f7e0a --- /dev/null +++ b/web/src/shared/utils/time.ts @@ -0,0 +1,36 @@ +/** + * Formats a duration in milliseconds to a human-readable string + */ +export function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days}d ${hours % 24}h`; + } else if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } +} + +/** + * Calculates duration from a start time to now + */ +export function getDurationFromStart(startTime: string): number { + const start = new Date(startTime).getTime(); + const now = Date.now(); + return now - start; +} + +/** + * Formats session duration for display + */ +export function formatSessionDuration(startedAt: string): string { + const duration = getDurationFromStart(startedAt); + return formatDuration(duration); +}