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 isAuthenticated = false;
@state() private sidebarCollapsed = this.loadSidebarState(); @state() private sidebarCollapsed = this.loadSidebarState();
@state() private sidebarWidth = this.loadSidebarWidth(); @state() private sidebarWidth = this.loadSidebarWidth();
@state() private userInitiatedSessionChange = false;
@state() private isResizing = false; @state() private isResizing = false;
@state() private mediaState: MediaQueryState = responsiveObserver.getCurrentState(); @state() private mediaState: MediaQueryState = responsiveObserver.getCurrentState();
@state() private showLogLink = false; @state() private showLogLink = false;
@ -70,6 +69,7 @@ export class VibeTunnelApp extends LitElement {
private initialLoadComplete = false; private initialLoadComplete = false;
private responsiveObserverInitialized = false; private responsiveObserverInitialized = false;
private initialRenderComplete = false; private initialRenderComplete = false;
private sidebarAnimationReady = false;
private hotReloadWs: WebSocket | null = null; private hotReloadWs: WebSocket | null = null;
private errorTimeoutId: number | 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 // Mark initial render as complete after a microtask to ensure DOM is settled
Promise.resolve().then(() => { Promise.resolve().then(() => {
this.initialRenderComplete = true; 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; 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 // Handle Escape to close the session and return to list view
if ( if (
e.key === 'Escape' && e.key === 'Escape' &&
@ -213,18 +223,10 @@ export class VibeTunnelApp extends LitElement {
const url = new URL(window.location.href); const url = new URL(window.location.href);
const sessionId = url.searchParams.get('session'); const sessionId = url.searchParams.get('session');
if (sessionId) { if (sessionId) {
// Try to find the session and navigate to it // Always navigate to the session view if a session ID is provided
const session = this.sessions.find((s) => s.id === sessionId); logger.log(`Navigating to session ${sessionId} from URL after auth`);
if (session) { this.selectedSessionId = sessionId;
this.userInitiatedSessionChange = false; this.currentView = 'session';
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`;
}
} }
} }
@ -350,20 +352,14 @@ export class VibeTunnelApp extends LitElement {
document.title = `VibeTunnel - ${sessionCount} Session${sessionCount !== 1 ? 's' : ''}`; 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') { if (this.selectedSessionId && this.currentView === 'session') {
const sessionExists = this.sessions.find((s) => s.id === this.selectedSessionId); const sessionExists = this.sessions.find((s) => s.id === this.selectedSessionId);
if (!sessionExists) { if (!sessionExists) {
// Session no longer exists, redirect to dashboard
logger.warn( 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) { } else if (response.status === 401) {
@ -637,8 +633,6 @@ export class VibeTunnelApp extends LitElement {
mediaStateIsMobile: this.mediaState.isMobile, mediaStateIsMobile: this.mediaState.isMobile,
}); });
this.userInitiatedSessionChange = true;
// Check if View Transitions API is supported // Check if View Transitions API is supported
if ('startViewTransition' in document && typeof document.startViewTransition === 'function') { if ('startViewTransition' in document && typeof document.startViewTransition === 'function') {
// Debug: Check what elements have view-transition-name before transition // Debug: Check what elements have view-transition-name before transition
@ -837,19 +831,17 @@ export class VibeTunnelApp extends LitElement {
private loadSidebarState(): boolean { private loadSidebarState(): boolean {
try { try {
const saved = localStorage.getItem('sidebarCollapsed'); 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; const isMobile = window.innerWidth < BREAKPOINTS.MOBILE;
// Force expanded on desktop regardless of localStorage for better UX // Respect saved state if it exists, otherwise default based on device type
const result = isMobile ? (saved !== null ? saved === 'true' : true) : false; const result = saved !== null ? saved === 'true' : isMobile;
logger.debug('Loading sidebar state:', { logger.debug('Loading sidebar state:', {
savedValue: saved, savedValue: saved,
windowWidth: window.innerWidth, windowWidth: window.innerWidth,
mobileBreakpoint: BREAKPOINTS.MOBILE, mobileBreakpoint: BREAKPOINTS.MOBILE,
isMobile, isMobile,
forcedDesktopExpanded: !isMobile, hasSavedState: saved !== null,
resultingState: result ? 'collapsed' : 'expanded', resultingState: result ? 'collapsed' : 'expanded',
}); });
@ -994,26 +986,17 @@ export class VibeTunnelApp extends LitElement {
} }
if (sessionId) { if (sessionId) {
// Check if we have sessions loaded // Always navigate to the session view if a session ID is provided
if (this.sessions.length === 0 && this.isAuthenticated) { // The session-view component will handle loading and error cases
// Sessions not loaded yet, load them first logger.log(`Navigating to session ${sessionId} from URL`);
await this.loadSessions(); this.selectedSessionId = sessionId;
} this.currentView = 'session';
// Now check if the session exists // Load sessions in the background if not already loaded
const session = this.sessions.find((s) => s.id === sessionId); if (this.sessions.length === 0 && this.isAuthenticated) {
if (session) { this.loadSessions().catch((error) => {
this.selectedSessionId = sessionId; logger.error('Error loading sessions:', error);
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());
} }
} else { } else {
this.selectedSessionId = null; 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 baseClasses = 'bg-dark-bg-secondary border-r border-dark-border flex flex-col';
const isMobile = this.mediaState.isMobile; const isMobile = this.mediaState.isMobile;
const transitionClass = // Only apply transition class when animations are ready (not during initial load)
this.initialRenderComplete && !isMobile const transitionClass = this.sidebarAnimationReady && !isMobile ? 'sidebar-transition' : '';
? this.userInitiatedSessionChange
? 'sidebar-transition'
: ''
: '';
const mobileClasses = isMobile ? 'absolute left-0 top-0 bottom-0 z-30 flex' : transitionClass; const mobileClasses = isMobile ? 'absolute left-0 top-0 bottom-0 z-30 flex' : transitionClass;
const collapsedClasses = this.sidebarCollapsed const collapsedClasses = this.sidebarCollapsed
? isMobile ? isMobile
? 'hidden mobile-sessions-sidebar collapsed' ? '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 : isMobile
? 'overflow-visible sm:translate-x-0 flex mobile-sessions-sidebar expanded' ? 'overflow-visible sm:translate-x-0 flex mobile-sessions-sidebar expanded'
: 'overflow-visible sm:translate-x-0 flex'; : 'overflow-visible sm:translate-x-0 flex';
@ -1134,12 +1113,18 @@ export class VibeTunnelApp extends LitElement {
} }
private get sidebarStyles(): string { private get sidebarStyles(): string {
if (!this.showSplitView || this.sidebarCollapsed) { if (!this.showSplitView) {
const isMobile = this.mediaState.isMobile; return '';
return this.showSplitView && this.sidebarCollapsed && !isMobile ? 'width: 0px;' : '';
} }
const isMobile = this.mediaState.isMobile; const isMobile = this.mediaState.isMobile;
if (this.sidebarCollapsed) {
// Hide completely on both desktop and mobile
return 'width: 0px;';
}
// Expanded state
if (isMobile) { if (isMobile) {
return `width: calc(100vw - ${SIDEBAR.MOBILE_RIGHT_MARGIN}px);`; return `width: calc(100vw - ${SIDEBAR.MOBILE_RIGHT_MARGIN}px);`;
} }
@ -1282,6 +1267,7 @@ export class VibeTunnelApp extends LitElement {
@open-settings=${this.handleOpenSettings} @open-settings=${this.handleOpenSettings}
@logout=${this.handleLogout} @logout=${this.handleLogout}
@navigate-to-list=${this.handleNavigateToList} @navigate-to-list=${this.handleNavigateToList}
@toggle-sidebar=${this.handleToggleSidebar}
></app-header> ></app-header>
<div class="${this.showSplitView ? 'flex-1 overflow-y-auto' : 'flex-1'} bg-dark-bg-secondary"> <div class="${this.showSplitView ? 'flex-1 overflow-y-auto' : 'flex-1'} bg-dark-bg-secondary">
<session-list <session-list
@ -1290,6 +1276,7 @@ export class VibeTunnelApp extends LitElement {
.hideExited=${this.hideExited} .hideExited=${this.hideExited}
.selectedSessionId=${this.selectedSessionId} .selectedSessionId=${this.selectedSessionId}
.compactMode=${showSplitView} .compactMode=${showSplitView}
.collapsed=${this.sidebarCollapsed}
.authClient=${authClient} .authClient=${authClient}
@session-killed=${this.handleSessionKilled} @session-killed=${this.handleSessionKilled}
@refresh=${this.handleRefresh} @refresh=${this.handleRefresh}
@ -1336,6 +1323,7 @@ export class VibeTunnelApp extends LitElement {
.disableFocusManagement=${this.hasActiveOverlay} .disableFocusManagement=${this.hasActiveOverlay}
@navigate-to-list=${this.handleNavigateToList} @navigate-to-list=${this.handleNavigateToList}
@toggle-sidebar=${this.handleToggleSidebar} @toggle-sidebar=${this.handleToggleSidebar}
@create-session=${this.handleCreateSession}
@session-status-changed=${this.handleSessionStatusChanged} @session-status-changed=${this.handleSessionStatusChanged}
></session-view> ></session-view>
` `

View file

@ -10,6 +10,7 @@
* @fires clean-exited-sessions - When clean exited button is clicked * @fires clean-exited-sessions - When clean exited button is clicked
* @fires open-file-browser - When browse button is clicked * @fires open-file-browser - When browse button is clicked
* @fires logout - When logout is clicked * @fires logout - When logout is clicked
* @fires toggle-sidebar - When sidebar toggle button is clicked
*/ */
import { html, LitElement } from 'lit'; import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
@ -60,6 +61,7 @@ export class AppHeader extends LitElement {
@open-settings=${this.forwardEvent} @open-settings=${this.forwardEvent}
@logout=${this.forwardEvent} @logout=${this.forwardEvent}
@navigate-to-list=${this.forwardEvent} @navigate-to-list=${this.forwardEvent}
@toggle-sidebar=${this.forwardEvent}
></sidebar-header> ></sidebar-header>
`; `;
} }

View file

@ -22,6 +22,7 @@ import { repeat } from 'lit/directives/repeat.js';
import type { Session } from '../../shared/types.js'; import type { Session } from '../../shared/types.js';
import type { AuthClient } from '../services/auth-client.js'; import type { AuthClient } from '../services/auth-client.js';
import './session-card.js'; import './session-card.js';
import { formatSessionDuration } from '../../shared/utils/time.js';
import { createLogger } from '../utils/logger.js'; import { createLogger } from '../utils/logger.js';
import { formatPathForDisplay } from '../utils/path-utils.js'; import { formatPathForDisplay } from '../utils/path-utils.js';
@ -238,14 +239,40 @@ export class SessionList extends LitElement {
? html` ? html`
<!-- Compact list item for sidebar --> <!-- Compact list item for sidebar -->
<div <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 session.id === this.selectedSessionId
? 'bg-dark-bg-tertiary border border-accent-green shadow-sm' ? 'bg-dark-bg-tertiary border border-accent-green shadow-sm'
: 'border border-transparent' : 'border border-transparent hover:border-dark-border'
}" }"
@click=${() => @click=${() =>
this.handleSessionSelect({ detail: session } as CustomEvent)} 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="flex-1 min-w-0">
<div <div
class="text-sm font-mono text-accent-green truncate" class="text-sm font-mono text-accent-green truncate"
@ -290,32 +317,26 @@ export class SessionList extends LitElement {
})()} })()}
</div> </div>
</div> </div>
<div class="flex items-center gap-2 flex-shrink-0">
<div <!-- Right side: duration and close button -->
class="w-2 h-2 rounded-full ${ <div class="relative flex items-center flex-shrink-0">
session.status === 'running' <!-- Session duration (hidden on group hover on desktop) -->
? session.activityStatus?.specificStatus <div class="text-xs text-dark-text-muted font-mono transition-opacity ${
? 'bg-accent-green animate-pulse' // Claude active 'ontouchstart' in window ? '' : 'group-hover:opacity-0'
: session.activityStatus?.isActive }">
? 'bg-status-success' // Generic active ${session.startedAt ? formatSessionDuration(session.startedAt) : ''}
: 'bg-status-success ring-1 ring-status-success' // Idle (outline) </div>
: 'bg-status-warning'
}" <!-- Close button (overlaps duration on hover) -->
title="${
session.status === 'running' && session.activityStatus
? session.activityStatus.specificStatus
? `Active: ${session.activityStatus.specificStatus.app}`
: session.activityStatus.isActive
? 'Active'
: 'Idle'
: session.status
}"
></div>
${ ${
session.status === 'running' || session.status === 'exited' session.status === 'running' || session.status === 'exited'
? html` ? html`
<button <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) => { @click=${async (e: Event) => {
e.stopPropagation(); e.stopPropagation();
// Kill the session // 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) { private handleSessionExit(e: Event) {
const customEvent = e as CustomEvent; const customEvent = e as CustomEvent;
logger.log('session exit event received', customEvent.detail); logger.log('session exit event received', customEvent.detail);
@ -1042,6 +1052,7 @@ export class SessionView extends LitElement {
.widthTooltip=${this.getWidthTooltip()} .widthTooltip=${this.getWidthTooltip()}
.onBack=${() => this.handleBack()} .onBack=${() => this.handleBack()}
.onSidebarToggle=${() => this.handleSidebarToggle()} .onSidebarToggle=${() => this.handleSidebarToggle()}
.onCreateSession=${() => this.handleCreateSession()}
.onOpenFileBrowser=${() => this.handleOpenFileBrowser()} .onOpenFileBrowser=${() => this.handleOpenFileBrowser()}
.onOpenImagePicker=${() => this.handleOpenFilePicker()} .onOpenImagePicker=${() => this.handleOpenFilePicker()}
.onMaxWidthToggle=${() => this.handleMaxWidthToggle()} .onMaxWidthToggle=${() => this.handleMaxWidthToggle()}

View file

@ -32,6 +32,7 @@ export class SessionHeader extends LitElement {
@property({ type: Function }) onBack?: () => void; @property({ type: Function }) onBack?: () => void;
@property({ type: Function }) onSidebarToggle?: () => void; @property({ type: Function }) onSidebarToggle?: () => void;
@property({ type: Function }) onOpenFileBrowser?: () => void; @property({ type: Function }) onOpenFileBrowser?: () => void;
@property({ type: Function }) onCreateSession?: () => void;
@property({ type: Function }) onOpenImagePicker?: () => void; @property({ type: Function }) onOpenImagePicker?: () => void;
@property({ type: Function }) onMaxWidthToggle?: () => void; @property({ type: Function }) onMaxWidthToggle?: () => void;
@property({ type: Function }) onWidthSelect?: (width: number) => void; @property({ type: Function }) onWidthSelect?: (width: number) => void;
@ -76,35 +77,40 @@ export class SessionHeader extends LitElement {
return html` return html`
<!-- Compact Header --> <!-- Compact Header -->
<div <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" 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.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));" 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"> <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 this.showSidebarToggle && this.sidebarCollapsed
? html` ? html`
<button <div class="flex items-center gap-2">
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" <button
@click=${() => this.onSidebarToggle?.()} 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"
title="Show sessions" @click=${() => this.onSidebarToggle?.()}
> title="Show sidebar (⌘B)"
<!-- Hamburger menu icon --> aria-label="Show sidebar"
<svg aria-expanded="false"
width="16" aria-controls="sidebar"
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> <!-- Right chevron icon to expand sidebar -->
<line x1="3" y1="12" x2="21" y2="12"></line> <svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<line x1="3" y1="18" x2="21" y2="18"></line> <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> </svg>
</button> </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)));" style="padding-top: max(0.75rem, calc(0.75rem + env(safe-area-inset-top)));"
> >
<!-- Compact layout for sidebar --> <!-- Compact layout for sidebar -->
<div class="flex items-center justify-between"> <div class="flex items-center gap-2">
<!-- Title and logo --> <!-- Toggle button -->
<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" title="Go to home"
@click=${this.handleHomeClick} @click=${this.handleHomeClick}
> >
@ -41,7 +55,7 @@ export class SidebarHeader extends HeaderBase {
</button> </button>
<!-- Action buttons group --> <!-- Action buttons group -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1 flex-shrink-0">
<!-- Notification button --> <!-- Notification button -->
<notification-status <notification-status
@open-settings=${() => this.dispatchEvent(new CustomEvent('open-settings'))} @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);
}