Add sidebar toggle button with collapse/expand functionality (#175)

This commit is contained in:
Peter Steinberger 2025-07-01 14:52:56 +01:00 committed by GitHub
parent cf7ada95e3
commit acf91e228d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 187 additions and 109 deletions

View file

@ -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}
></app-header>
<div class="${this.showSplitView ? 'flex-1 overflow-y-auto' : 'flex-1'} bg-dark-bg-secondary">
<session-list
@ -1290,6 +1276,7 @@ export class VibeTunnelApp extends LitElement {
.hideExited=${this.hideExited}
.selectedSessionId=${this.selectedSessionId}
.compactMode=${showSplitView}
.collapsed=${this.sidebarCollapsed}
.authClient=${authClient}
@session-killed=${this.handleSessionKilled}
@refresh=${this.handleRefresh}
@ -1336,6 +1323,7 @@ export class VibeTunnelApp extends LitElement {
.disableFocusManagement=${this.hasActiveOverlay}
@navigate-to-list=${this.handleNavigateToList}
@toggle-sidebar=${this.handleToggleSidebar}
@create-session=${this.handleCreateSession}
@session-status-changed=${this.handleSessionStatusChanged}
></session-view>
`

View file

@ -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}
></sidebar-header>
`;
}

View file

@ -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`
<!-- 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 ${
class="group flex items-center gap-3 p-3 rounded-md cursor-pointer transition-all hover:bg-dark-bg-tertiary hover:shadow-md ${
session.id === this.selectedSessionId
? 'bg-dark-bg-tertiary border border-accent-green shadow-sm'
: 'border border-transparent'
: 'border border-transparent hover:border-dark-border'
}"
@click=${() =>
this.handleSessionSelect({ detail: session } as CustomEvent)}
>
<!-- Activity indicator on the left -->
<div
class="w-2 h-2 rounded-full flex-shrink-0 ${
session.status === 'running'
? session.activityStatus?.specificStatus
? 'bg-status-warning' // Claude active - orange, no pulse
: session.activityStatus?.isActive
? 'bg-status-success' // Generic active
: 'bg-status-success ring-1 ring-status-success' // Idle (outline)
: 'bg-status-warning'
}"
title="${
session.status === 'running' && session.activityStatus
? session.activityStatus.specificStatus
? `Active: ${session.activityStatus.specificStatus.app}`
: session.activityStatus.isActive
? 'Active'
: 'Idle'
: session.status
}"
></div>
<!-- Divider line -->
<div class="w-px h-8 bg-dark-border"></div>
<!-- Session content -->
<div class="flex-1 min-w-0">
<div
class="text-sm font-mono text-accent-green truncate"
@ -290,32 +317,26 @@ export class SessionList extends LitElement {
})()}
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<div
class="w-2 h-2 rounded-full ${
session.status === 'running'
? session.activityStatus?.specificStatus
? 'bg-accent-green animate-pulse' // Claude active
: session.activityStatus?.isActive
? 'bg-status-success' // Generic active
: 'bg-status-success ring-1 ring-status-success' // Idle (outline)
: 'bg-status-warning'
}"
title="${
session.status === 'running' && session.activityStatus
? session.activityStatus.specificStatus
? `Active: ${session.activityStatus.specificStatus.app}`
: session.activityStatus.isActive
? 'Active'
: 'Idle'
: session.status
}"
></div>
<!-- Right side: duration and close button -->
<div class="relative flex items-center flex-shrink-0">
<!-- Session duration (hidden on group hover on desktop) -->
<div class="text-xs text-dark-text-muted font-mono transition-opacity ${
'ontouchstart' in window ? '' : 'group-hover:opacity-0'
}">
${session.startedAt ? formatSessionDuration(session.startedAt) : ''}
</div>
<!-- Close button (overlaps duration on hover) -->
${
session.status === 'running' || session.status === 'exited'
? html`
<button
class="btn-ghost text-status-error p-1 rounded hover:bg-dark-bg"
class="btn-ghost text-status-error p-1 rounded transition-opacity absolute right-0 ${
'ontouchstart' in window
? 'opacity-100 static ml-2'
: 'opacity-0 group-hover:opacity-100'
} hover:bg-dark-bg hover:shadow-sm"
@click=${async (e: Event) => {
e.stopPropagation();
// Kill the session

View file

@ -496,6 +496,16 @@ export class SessionView extends LitElement {
);
}
private handleCreateSession() {
// Dispatch event to create a new session
this.dispatchEvent(
new CustomEvent('create-session', {
bubbles: true,
composed: true,
})
);
}
private handleSessionExit(e: Event) {
const customEvent = e as CustomEvent;
logger.log('session exit event received', customEvent.detail);
@ -1042,6 +1052,7 @@ export class SessionView extends LitElement {
.widthTooltip=${this.getWidthTooltip()}
.onBack=${() => this.handleBack()}
.onSidebarToggle=${() => this.handleSidebarToggle()}
.onCreateSession=${() => this.handleCreateSession()}
.onOpenFileBrowser=${() => this.handleOpenFileBrowser()}
.onOpenImagePicker=${() => this.handleOpenFilePicker()}
.onMaxWidthToggle=${() => this.handleMaxWidthToggle()}

View file

@ -32,6 +32,7 @@ export class SessionHeader extends LitElement {
@property({ type: Function }) onBack?: () => void;
@property({ type: Function }) onSidebarToggle?: () => void;
@property({ type: Function }) onOpenFileBrowser?: () => void;
@property({ type: Function }) onCreateSession?: () => void;
@property({ type: Function }) onOpenImagePicker?: () => void;
@property({ type: Function }) onMaxWidthToggle?: () => void;
@property({ type: Function }) onWidthSelect?: (width: number) => void;
@ -76,35 +77,40 @@ export class SessionHeader extends LitElement {
return html`
<!-- Compact Header -->
<div
class="flex items-center justify-between px-3 py-2 border-b border-dark-border text-sm min-w-0 bg-dark-bg-secondary"
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));"
class="flex items-center justify-between border-b border-dark-border text-sm min-w-0 bg-dark-bg-secondary p-3"
style="padding-top: max(0.75rem, calc(0.75rem + 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">
<!-- Mobile Hamburger Menu Button (only on phones, only when session is shown) -->
<!-- Sidebar Toggle and Create Session Buttons (shown when sidebar is collapsed) -->
${
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.onSidebarToggle?.()}
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"
<div class="flex items-center gap-2">
<button
class="bg-dark-bg-tertiary border border-dark-border rounded-lg p-1.5 font-mono text-dark-text-muted transition-all duration-300 hover:text-dark-text hover:bg-dark-bg hover:border-accent-green flex-shrink-0"
@click=${() => this.onSidebarToggle?.()}
title="Show sidebar (⌘B)"
aria-label="Show sidebar"
aria-expanded="false"
aria-controls="sidebar"
>
<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>
<!-- Right chevron icon to expand sidebar -->
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"/>
</svg>
</button>
<!-- Create Session button -->
<button
class="bg-dark-bg-tertiary border border-accent-green text-accent-green rounded-lg p-1.5 font-mono transition-all duration-300 hover:bg-accent-green hover:text-dark-bg flex-shrink-0"
@click=${() => this.onCreateSession?.()}
title="Create New Session (⌘K)"
>
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/>
</svg>
</button>
</div>
`
: ''
}

View file

@ -20,10 +20,24 @@ export class SidebarHeader extends HeaderBase {
style="padding-top: max(0.75rem, calc(0.75rem + env(safe-area-inset-top)));"
>
<!-- Compact layout for sidebar -->
<div class="flex items-center justify-between">
<!-- Title and logo -->
<div class="flex items-center gap-2">
<!-- Toggle button -->
<button
class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer group"
class="p-2 text-dark-text-muted hover:text-dark-text rounded-lg hover:bg-dark-bg-tertiary transition-all duration-200 flex-shrink-0"
@click=${() => this.dispatchEvent(new CustomEvent('toggle-sidebar'))}
title="Collapse sidebar (⌘B)"
aria-label="Collapse sidebar"
aria-expanded="true"
aria-controls="sidebar"
>
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"/>
</svg>
</button>
<!-- Title and logo with flex-grow for centering -->
<button
class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer group flex-grow"
title="Go to home"
@click=${this.handleHomeClick}
>
@ -41,7 +55,7 @@ export class SidebarHeader extends HeaderBase {
</button>
<!-- Action buttons group -->
<div class="flex items-center gap-1">
<div class="flex items-center gap-1 flex-shrink-0">
<!-- Notification button -->
<notification-status
@open-settings=${() => this.dispatchEvent(new CustomEvent('open-settings'))}

View file

@ -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);
}