mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Add sidebar toggle button with collapse/expand functionality (#175)
This commit is contained in:
parent
cf7ada95e3
commit
acf91e228d
7 changed files with 187 additions and 109 deletions
|
|
@ -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>
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`
|
`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'))}
|
||||||
|
|
|
||||||
36
web/src/shared/utils/time.ts
Normal file
36
web/src/shared/utils/time.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue