mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
feat(web): implement vertical tabs with Arc-style persistent sidebar and comprehensive mobile UX
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
6b71cd79f0
commit
bc370452ad
12 changed files with 1341 additions and 400 deletions
|
|
@ -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`
|
||||
<!-- Error notification overlay -->
|
||||
${
|
||||
|
|
@ -683,49 +933,102 @@ export class VibeTunnelApp extends LitElement {
|
|||
@show-ssh-key-manager=${this.handleShowSSHKeyManager}
|
||||
></auth-login>
|
||||
`
|
||||
: this.currentView === 'session' && this.selectedSessionId
|
||||
? keyed(
|
||||
this.selectedSessionId,
|
||||
html`
|
||||
<session-view
|
||||
.session=${this.sessions.find((s) => s.id === this.selectedSessionId)}
|
||||
@navigate-to-list=${this.handleNavigateToList}
|
||||
></session-view>
|
||||
`
|
||||
)
|
||||
: html`
|
||||
<div>
|
||||
<app-header
|
||||
.sessions=${this.sessions}
|
||||
.hideExited=${this.hideExited}
|
||||
.currentUser=${this.authClient.getCurrentUser()?.userId || null}
|
||||
.authMethod=${this.authClient.getCurrentUser()?.authMethod || null}
|
||||
@create-session=${this.handleCreateSession}
|
||||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
@kill-all-sessions=${this.handleKillAll}
|
||||
@clean-exited-sessions=${this.handleCleanExited}
|
||||
@open-file-browser=${this.handleOpenFileBrowser}
|
||||
@open-notification-settings=${this.handleShowNotificationSettings}
|
||||
@logout=${this.handleLogout}
|
||||
></app-header>
|
||||
<session-list
|
||||
.sessions=${this.sessions}
|
||||
.loading=${this.loading}
|
||||
.hideExited=${this.hideExited}
|
||||
.showCreateModal=${this.showCreateModal}
|
||||
.authClient=${this.authClient}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@session-created=${this.handleSessionCreated}
|
||||
@create-modal-close=${this.handleCreateModalClose}
|
||||
@refresh=${this.handleRefresh}
|
||||
@error=${this.handleError}
|
||||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
@kill-all-sessions=${this.handleKillAll}
|
||||
@navigate-to-session=${this.handleNavigateToSession}
|
||||
></session-list>
|
||||
: html`
|
||||
<!-- Main content with split view support -->
|
||||
<div class="${this.mainContainerClasses}">
|
||||
<!-- Mobile overlay when sidebar is open -->
|
||||
${this.shouldShowMobileOverlay
|
||||
? html`
|
||||
<!-- Translucent overlay over session content -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black bg-opacity-10 sm:hidden transition-opacity"
|
||||
style="left: calc(100vw - ${SIDEBAR.MOBILE_RIGHT_MARGIN}px); transition-duration: ${TRANSITIONS.MOBILE_SLIDE}ms;"
|
||||
@click=${this.handleToggleSidebar}
|
||||
></div>
|
||||
<!-- Clickable area behind sidebar -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black bg-opacity-50 sm:hidden transition-opacity"
|
||||
style="right: ${SIDEBAR.MOBILE_RIGHT_MARGIN}px; transition-duration: ${TRANSITIONS.MOBILE_SLIDE}ms;"
|
||||
@click=${this.handleToggleSidebar}
|
||||
></div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<!-- Sidebar with session list - always visible on desktop -->
|
||||
<div class="${this.sidebarClasses}" style="${this.sidebarStyles}">
|
||||
<app-header
|
||||
.sessions=${this.sessions}
|
||||
.hideExited=${this.hideExited}
|
||||
.showSplitView=${showSplitView}
|
||||
.currentUser=${this.authClient.getCurrentUser()?.userId || null}
|
||||
.authMethod=${this.authClient.getCurrentUser()?.authMethod || null}
|
||||
@create-session=${this.handleCreateSession}
|
||||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
@kill-all-sessions=${this.handleKillAll}
|
||||
@clean-exited-sessions=${this.handleCleanExited}
|
||||
@open-file-browser=${this.handleOpenFileBrowser}
|
||||
@open-notification-settings=${this.handleShowNotificationSettings}
|
||||
@logout=${this.handleLogout}
|
||||
></app-header>
|
||||
<div class="${this.showSplitView ? 'flex-1 overflow-y-auto' : 'flex-1'} bg-dark-bg">
|
||||
<session-list
|
||||
.sessions=${this.sessions}
|
||||
.loading=${this.loading}
|
||||
.hideExited=${this.hideExited}
|
||||
.showCreateModal=${this.showCreateModal}
|
||||
.selectedSessionId=${this.selectedSessionId}
|
||||
.compactMode=${showSplitView}
|
||||
.authClient=${this.authClient}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@session-created=${this.handleSessionCreated}
|
||||
@create-modal-close=${this.handleCreateModalClose}
|
||||
@refresh=${this.handleRefresh}
|
||||
@error=${this.handleError}
|
||||
@hide-exited-change=${this.handleHideExitedChange}
|
||||
@kill-all-sessions=${this.handleKillAll}
|
||||
@navigate-to-session=${this.handleNavigateToSession}
|
||||
@open-file-browser=${() => (this.showFileBrowser = true)}
|
||||
></session-list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle for sidebar -->
|
||||
${this.shouldShowResizeHandle
|
||||
? html`
|
||||
<div
|
||||
class="w-1 bg-dark-border hover:bg-accent-green cursor-ew-resize transition-colors ${this
|
||||
.isResizing
|
||||
? 'bg-accent-green'
|
||||
: ''}"
|
||||
style="transition-duration: ${TRANSITIONS.RESIZE_HANDLE}ms;"
|
||||
@mousedown=${this.handleResizeStart}
|
||||
title="Drag to resize sidebar"
|
||||
></div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<!-- Main content area -->
|
||||
${showSplitView
|
||||
? html`
|
||||
<div class="flex-1 relative sm:static transition-none">
|
||||
${keyed(
|
||||
this.selectedSessionId,
|
||||
html`
|
||||
<session-view
|
||||
.session=${selectedSession}
|
||||
.showBackButton=${false}
|
||||
.showSidebarToggle=${true}
|
||||
.sidebarCollapsed=${this.sidebarCollapsed}
|
||||
@navigate-to-list=${this.handleNavigateToList}
|
||||
@toggle-sidebar=${this.handleToggleSidebar}
|
||||
></session-view>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
: ''}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<!-- File Browser Modal -->
|
||||
<file-browser
|
||||
|
|
@ -755,10 +1058,10 @@ export class VibeTunnelApp extends LitElement {
|
|||
></ssh-key-manager>
|
||||
|
||||
<!-- Version and logs link in bottom right -->
|
||||
<div class="fixed bottom-4 right-4 text-dark-text-muted text-xs font-mono">
|
||||
<div class="fixed bottom-4 right-4 text-dark-text-muted text-xs font-mono z-20">
|
||||
<a href="/logs" class="hover:text-dark-text transition-colors">Logs</a>
|
||||
<span class="ml-2">v${VERSION}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`
|
||||
<div
|
||||
class="app-header bg-dark-bg-secondary border-b border-dark-border px-6 py-3"
|
||||
style="padding-top: max(0.75rem, calc(0.75rem + env(safe-area-inset-top)));"
|
||||
>
|
||||
<!-- Mobile layout -->
|
||||
<div class="flex flex-col gap-4 sm:hidden">
|
||||
<!-- Centered VibeTunnel title with stats -->
|
||||
<div class="text-center flex flex-col items-center gap-2">
|
||||
<a
|
||||
href="/"
|
||||
class="text-2xl font-bold text-accent-green flex items-center gap-3 font-mono hover:opacity-80 transition-opacity cursor-pointer group"
|
||||
title="Go to home"
|
||||
>
|
||||
<terminal-icon size="28"></terminal-icon>
|
||||
<span class="group-hover:underline">VibeTunnel</span>
|
||||
</a>
|
||||
<p class="text-dark-text-muted text-sm font-mono">
|
||||
${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'}
|
||||
${exitedSessions.length > 0 ? `• ${exitedSessions.length} exited` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls row: left buttons and right buttons -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
${
|
||||
exitedSessions.length > 0
|
||||
? html`
|
||||
<button
|
||||
class="btn-secondary font-mono text-xs px-4 py-2 ${
|
||||
this.hideExited
|
||||
? ''
|
||||
: 'bg-accent-green text-dark-bg hover:bg-accent-green-darker'
|
||||
}"
|
||||
@click=${() =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('hide-exited-change', {
|
||||
detail: !this.hideExited,
|
||||
})
|
||||
)}
|
||||
>
|
||||
${
|
||||
this.hideExited
|
||||
? `Show (${exitedSessions.length})`
|
||||
: `Hide (${exitedSessions.length})`
|
||||
}
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
!this.hideExited && exitedSessions.length > 0
|
||||
? html`
|
||||
<button
|
||||
class="btn-ghost font-mono text-xs text-status-warning"
|
||||
@click=${this.handleCleanExited}
|
||||
>
|
||||
Clean Exited
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
runningSessions.length > 0 && !this.killingAll
|
||||
? html`
|
||||
<button
|
||||
class="btn-ghost font-mono text-xs text-status-error"
|
||||
@click=${this.handleKillAll}
|
||||
>
|
||||
Kill (${runningSessions.length})
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn-secondary font-mono text-xs px-3 py-2"
|
||||
@click=${this.handleOpenFileBrowser}
|
||||
title="Browse Files"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"
|
||||
/>
|
||||
</svg>
|
||||
Browse
|
||||
</span>
|
||||
</button>
|
||||
<notification-status
|
||||
@open-settings=${() =>
|
||||
this.dispatchEvent(new CustomEvent('open-notification-settings'))}
|
||||
></notification-status>
|
||||
<button
|
||||
class="btn-primary font-mono text-xs px-4 py-2 vt-create-button"
|
||||
@click=${this.handleCreateSession}
|
||||
style="view-transition-name: create-session-button"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop layout: single row -->
|
||||
<div class="hidden sm:flex sm:items-center sm:justify-between">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer group"
|
||||
title="Go to home"
|
||||
>
|
||||
<terminal-icon size="32"></terminal-icon>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-accent-green font-mono group-hover:underline">
|
||||
VibeTunnel
|
||||
</h1>
|
||||
<p class="text-dark-text-muted text-sm font-mono">
|
||||
${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'}
|
||||
${exitedSessions.length > 0 ? `• ${exitedSessions.length} exited` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
${
|
||||
exitedSessions.length > 0
|
||||
? html`
|
||||
<button
|
||||
class="btn-secondary font-mono text-xs px-4 py-2 ${
|
||||
this.hideExited
|
||||
? ''
|
||||
: 'bg-accent-green text-dark-bg hover:bg-accent-green-darker'
|
||||
}"
|
||||
@click=${() =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('hide-exited-change', {
|
||||
detail: !this.hideExited,
|
||||
})
|
||||
)}
|
||||
>
|
||||
${
|
||||
this.hideExited
|
||||
? `Show Exited (${exitedSessions.length})`
|
||||
: `Hide Exited (${exitedSessions.length})`
|
||||
}
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
<div class="flex gap-2">
|
||||
${
|
||||
!this.hideExited && this.sessions.filter((s) => s.status === 'exited').length > 0
|
||||
? html`
|
||||
<button
|
||||
class="btn-ghost font-mono text-xs text-status-warning"
|
||||
@click=${this.handleCleanExited}
|
||||
>
|
||||
Clean Exited
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
runningSessions.length > 0 && !this.killingAll
|
||||
? html`
|
||||
<button
|
||||
class="btn-ghost font-mono text-xs text-status-error"
|
||||
@click=${this.handleKillAll}
|
||||
>
|
||||
Kill All (${runningSessions.length})
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
<button
|
||||
class="btn-secondary font-mono text-xs px-4 py-2"
|
||||
@click=${this.handleOpenFileBrowser}
|
||||
title="Browse Files (⌘O)"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"
|
||||
/>
|
||||
</svg>
|
||||
Browse
|
||||
</span>
|
||||
</button>
|
||||
<notification-status
|
||||
@open-settings=${() =>
|
||||
this.dispatchEvent(new CustomEvent('open-notification-settings'))}
|
||||
></notification-status>
|
||||
<button
|
||||
class="btn-primary font-mono text-xs px-4 py-2 vt-create-button"
|
||||
@click=${this.handleCreateSession}
|
||||
style="view-transition-name: create-session-button"
|
||||
>
|
||||
Create Session
|
||||
</button>
|
||||
${
|
||||
this.currentUser
|
||||
? html`
|
||||
<div class="user-menu-container relative">
|
||||
<button
|
||||
class="btn-ghost font-mono text-xs text-dark-text flex items-center gap-1"
|
||||
@click=${this.toggleUserMenu}
|
||||
title="User menu"
|
||||
>
|
||||
<span>${this.currentUser}</span>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
class="transition-transform ${this.showUserMenu ? 'rotate-180' : ''}"
|
||||
>
|
||||
<path d="M5 7L1 3h8z" />
|
||||
</svg>
|
||||
</button>
|
||||
${
|
||||
this.showUserMenu
|
||||
? html`
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 bg-dark-surface border border-dark-border rounded shadow-lg py-1 z-50 min-w-32"
|
||||
>
|
||||
<div
|
||||
class="px-3 py-2 text-xs text-dark-text-muted border-b border-dark-border"
|
||||
>
|
||||
${this.authMethod || 'authenticated'}
|
||||
</div>
|
||||
<button
|
||||
class="w-full text-left px-3 py-2 text-xs font-mono text-status-warning hover:bg-dark-bg-secondary hover:text-status-error"
|
||||
@click=${this.handleLogout}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<sidebar-header
|
||||
.sessions=${this.sessions}
|
||||
.hideExited=${this.hideExited}
|
||||
.currentUser=${this.currentUser}
|
||||
.authMethod=${this.authMethod}
|
||||
@create-session=${this.forwardEvent}
|
||||
@hide-exited-change=${this.forwardEvent}
|
||||
@kill-all-sessions=${this.forwardEvent}
|
||||
@clean-exited-sessions=${this.forwardEvent}
|
||||
@logout=${this.forwardEvent}
|
||||
></sidebar-header>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
private renderFullHeader() {
|
||||
return html`
|
||||
<full-header
|
||||
.sessions=${this.sessions}
|
||||
.hideExited=${this.hideExited}
|
||||
.currentUser=${this.currentUser}
|
||||
.authMethod=${this.authMethod}
|
||||
@create-session=${this.forwardEvent}
|
||||
@hide-exited-change=${this.forwardEvent}
|
||||
@kill-all-sessions=${this.forwardEvent}
|
||||
@clean-exited-sessions=${this.forwardEvent}
|
||||
@open-file-browser=${this.forwardEvent}
|
||||
@open-notification-settings=${this.forwardEvent}
|
||||
@logout=${this.forwardEvent}
|
||||
></full-header>
|
||||
`;
|
||||
}
|
||||
}
|
||||
190
web/src/client/components/full-header.ts
Normal file
190
web/src/client/components/full-header.ts
Normal file
|
|
@ -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`
|
||||
<div
|
||||
class="app-header bg-dark-bg-secondary border-b border-dark-border p-6"
|
||||
style="padding-top: max(1.5rem, calc(1.5rem + env(safe-area-inset-top)));"
|
||||
>
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer group"
|
||||
title="Go to home"
|
||||
>
|
||||
<terminal-icon size="32"></terminal-icon>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-accent-green font-mono group-hover:underline">
|
||||
VibeTunnel
|
||||
</h1>
|
||||
<p class="text-dark-text-muted text-sm font-mono">
|
||||
${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'}
|
||||
running
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<button
|
||||
class="btn-secondary font-mono text-xs px-4 py-2"
|
||||
@click=${() => this.dispatchEvent(new CustomEvent('open-file-browser'))}
|
||||
title="Browse Files (⌘O)"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"
|
||||
/>
|
||||
</svg>
|
||||
Browse
|
||||
</span>
|
||||
</button>
|
||||
<notification-status
|
||||
@open-settings=${() =>
|
||||
this.dispatchEvent(new CustomEvent('open-notification-settings'))}
|
||||
></notification-status>
|
||||
<button
|
||||
class="btn-primary font-mono text-sm px-6 py-3 vt-create-button"
|
||||
@click=${this.handleCreateSession}
|
||||
>
|
||||
Create Session
|
||||
</button>
|
||||
${this.renderUserMenu()}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||
${this.renderExitedToggleButton(exitedSessions)}
|
||||
${this.renderActionButtons(exitedSessions, runningSessions)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderExitedToggleButton(exitedSessions: Session[]) {
|
||||
if (exitedSessions.length === 0) return '';
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="relative font-mono text-xs px-4 py-2 rounded-lg border transition-all duration-200 ${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'}"
|
||||
@click=${this.handleHideExitedToggle}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span>Show Exited (${exitedSessions.length})</span>
|
||||
<div
|
||||
class="w-8 h-4 rounded-full transition-colors duration-200 ${this.hideExited
|
||||
? 'bg-dark-border'
|
||||
: 'bg-dark-bg'}"
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 rounded-full transition-transform duration-200 mt-0.5 ${this.hideExited
|
||||
? 'translate-x-0.5 bg-dark-text-muted'
|
||||
: 'translate-x-4 bg-dark-bg'}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderActionButtons(exitedSessions: Session[], runningSessions: Session[]) {
|
||||
return html`
|
||||
${!this.hideExited && exitedSessions.length > 0
|
||||
? html`
|
||||
<button
|
||||
class="btn-ghost font-mono text-xs px-4 py-2 text-status-warning"
|
||||
@click=${this.handleCleanExited}
|
||||
>
|
||||
Clean Exited (${exitedSessions.length})
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
${runningSessions.length > 0 && !this.killingAll
|
||||
? html`
|
||||
<button
|
||||
class="btn-ghost font-mono text-xs px-4 py-2 text-status-error"
|
||||
@click=${this.handleKillAll}
|
||||
>
|
||||
Kill All (${runningSessions.length})
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
${this.killingAll
|
||||
? html`
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-status-error border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<span class="text-status-error font-mono text-xs">Killing...</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderUserMenu() {
|
||||
if (!this.currentUser) return '';
|
||||
|
||||
return html`
|
||||
<div class="user-menu-container relative">
|
||||
<button
|
||||
class="btn-ghost font-mono text-xs text-dark-text flex items-center gap-1"
|
||||
@click=${this.toggleUserMenu}
|
||||
title="User menu"
|
||||
>
|
||||
<span>${this.currentUser}</span>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
class="transition-transform ${this.showUserMenu ? 'rotate-180' : ''}"
|
||||
>
|
||||
<path d="M5 7L1 3h8z" />
|
||||
</svg>
|
||||
</button>
|
||||
${this.showUserMenu
|
||||
? html`
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 bg-dark-surface border border-dark-border rounded shadow-lg py-1 z-50 min-w-32"
|
||||
>
|
||||
<div
|
||||
class="px-3 py-2 text-xs text-dark-text-muted border-b border-dark-border"
|
||||
>
|
||||
${this.authMethod || 'authenticated'}
|
||||
</div>
|
||||
<button
|
||||
class="w-full text-left px-3 py-2 text-xs font-mono text-status-warning hover:bg-dark-bg-secondary hover:text-status-error"
|
||||
@click=${this.handleLogout}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
94
web/src/client/components/header-base.ts
Normal file
94
web/src/client/components/header-base.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
|||
}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="session-flex-responsive">
|
||||
: html`
|
||||
<div class="${this.compactMode ? 'space-y-2' : 'session-flex-responsive'}">
|
||||
${this.compactMode
|
||||
? html`
|
||||
<!-- Browse Files button as special tab -->
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 rounded-md cursor-pointer transition-all hover:bg-dark-bg-tertiary border border-dark-border bg-dark-bg-secondary"
|
||||
@click=${this.handleOpenFileBrowser}
|
||||
title="Browse Files (⌘O)"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-mono text-accent-green truncate">
|
||||
📁 Browse Files
|
||||
</div>
|
||||
<div class="text-xs text-dark-text-muted truncate">Open file browser</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span class="text-dark-text-muted text-xs">⌘O</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${repeat(
|
||||
filteredSessions,
|
||||
(session) => session.id,
|
||||
(session) => html`
|
||||
<session-card
|
||||
.session=${session}
|
||||
.authClient=${this.authClient}
|
||||
@session-select=${this.handleSessionSelect}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@session-kill-error=${this.handleSessionKillError}
|
||||
>
|
||||
</session-card>
|
||||
${this.compactMode
|
||||
? 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 ${session.id ===
|
||||
this.selectedSessionId
|
||||
? 'bg-dark-bg-tertiary border border-accent-green shadow-sm'
|
||||
: 'border border-transparent'}"
|
||||
@click=${() =>
|
||||
this.handleSessionSelect({ detail: session } as CustomEvent)}
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm font-mono text-accent-green truncate"
|
||||
title="${session.name || session.command}"
|
||||
>
|
||||
${session.name || session.command}
|
||||
</div>
|
||||
<div class="text-xs text-dark-text-muted truncate">
|
||||
${session.workingDir}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
class="${session.status === 'running'
|
||||
? 'text-status-success'
|
||||
: 'text-status-warning'} text-xs flex items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full ${session.status === 'running'
|
||||
? 'bg-status-success'
|
||||
: 'bg-status-warning'}"
|
||||
></div>
|
||||
${session.status}
|
||||
</span>
|
||||
${session.status === 'running' || session.status === 'exited'
|
||||
? html`
|
||||
<button
|
||||
class="btn-ghost text-status-error p-1 rounded hover:bg-dark-bg"
|
||||
@click=${async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
// Kill the session
|
||||
try {
|
||||
const endpoint =
|
||||
session.status === 'exited'
|
||||
? `/api/sessions/${session.id}/cleanup`
|
||||
: `/api/sessions/${session.id}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
headers: this.authClient.getAuthHeader(),
|
||||
});
|
||||
if (response.ok) {
|
||||
this.handleSessionKilled({
|
||||
detail: { sessionId: session.id },
|
||||
} as CustomEvent);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill session', error);
|
||||
}
|
||||
}}
|
||||
title="${session.status === 'running'
|
||||
? 'Kill session'
|
||||
: 'Clean up session'}"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<!-- Full session card for main view -->
|
||||
<session-card
|
||||
.session=${session}
|
||||
.authClient=${this.authClient}
|
||||
@session-select=${this.handleSessionSelect}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@session-kill-error=${this.handleSessionKillError}
|
||||
>
|
||||
</session-card>
|
||||
`}
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -258,4 +374,4 @@ export class SessionList extends LitElement {
|
|||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
|||
}
|
||||
</style>
|
||||
<div
|
||||
class="flex flex-col bg-black font-mono"
|
||||
class="flex flex-col bg-black font-mono relative"
|
||||
style="height: 100vh; height: 100dvh; outline: none !important; box-shadow: none !important;"
|
||||
>
|
||||
<!-- Compact Header -->
|
||||
|
|
@ -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));"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<button
|
||||
class="btn-secondary font-mono text-xs px-3 py-1 flex-shrink-0"
|
||||
@click=${this.handleBack}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<div class="text-dark-text min-w-0 flex-1 overflow-hidden">
|
||||
<!-- Mobile Hamburger Menu Button (only on phones, only when session is shown) -->
|
||||
${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.handleSidebarToggle}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
`
|
||||
: ''}
|
||||
${this.showBackButton
|
||||
? html`
|
||||
<button
|
||||
class="btn-secondary font-mono text-xs px-3 py-1 flex-shrink-0"
|
||||
@click=${this.handleBack}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
<div class="text-dark-text min-w-0 flex-1 overflow-hidden max-w-[50vw] sm:max-w-none">
|
||||
<div
|
||||
class="text-accent-green text-xs sm:text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
title="${
|
||||
|
|
@ -1354,7 +1397,7 @@ export class SessionView extends LitElement {
|
|||
this.session?.status === 'exited'
|
||||
? html`
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none z-50"
|
||||
class="fixed inset-0 flex items-center justify-center pointer-events-none z-[100]"
|
||||
>
|
||||
<div
|
||||
class="bg-dark-bg-secondary border border-dark-border ${this.getStatusColor()} font-medium text-sm tracking-wide px-4 py-2 rounded-lg shadow-lg"
|
||||
|
|
|
|||
135
web/src/client/components/sidebar-header.ts
Normal file
135
web/src/client/components/sidebar-header.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Sidebar Header Component
|
||||
*
|
||||
* Compact header for sidebar/split view with vertical 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';
|
||||
|
||||
@customElement('sidebar-header')
|
||||
export class SidebarHeader extends HeaderBase {
|
||||
render() {
|
||||
const runningSessions = this.runningSessions;
|
||||
const exitedSessions = this.exitedSessions;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="app-header sidebar-header bg-dark-bg-secondary border-b border-dark-border p-3"
|
||||
style="padding-top: max(0.75rem, calc(0.75rem + env(safe-area-inset-top)));"
|
||||
>
|
||||
<!-- Compact vertical layout for sidebar -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Title and logo -->
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer group"
|
||||
title="Go to home"
|
||||
>
|
||||
<terminal-icon size="20"></terminal-icon>
|
||||
<div class="min-w-0">
|
||||
<h1
|
||||
class="text-sm font-bold text-accent-green font-mono group-hover:underline truncate"
|
||||
>
|
||||
VibeTunnel
|
||||
</h1>
|
||||
<p class="text-dark-text-muted text-xs font-mono">
|
||||
${runningSessions.length} ${runningSessions.length === 1 ? 'session' : 'sessions'}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Compact action buttons -->
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<button
|
||||
class="btn-primary font-mono text-xs px-3 py-1.5 vt-create-button text-center max-w-[200px] w-full"
|
||||
@click=${this.handleCreateSession}
|
||||
>
|
||||
Create Session
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-1 w-full max-w-[200px]">
|
||||
${this.renderExitedToggleButton(exitedSessions, true)}
|
||||
${this.renderActionButtons(exitedSessions, runningSessions, true)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<button
|
||||
class="${buttonClass} ${stateClass}"
|
||||
@click=${this.handleHideExitedToggle}
|
||||
title="${this.hideExited
|
||||
? `Show ${exitedSessions.length} exited sessions`
|
||||
: `Hide ${exitedSessions.length} exited sessions`}"
|
||||
>
|
||||
<div class="flex items-center ${compact ? 'justify-between' : 'gap-2'}">
|
||||
<span>${compact ? 'Show Exited' : `Show Exited (${exitedSessions.length})`}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
${compact
|
||||
? html`<span class="text-xs opacity-75">(${exitedSessions.length})</span>`
|
||||
: ''}
|
||||
<div
|
||||
class="w-${compact ? '8' : '6'} h-${compact
|
||||
? '4'
|
||||
: '3'} rounded-full transition-colors duration-200 ${this.hideExited
|
||||
? 'bg-dark-border'
|
||||
: 'bg-dark-bg'}"
|
||||
>
|
||||
<div
|
||||
class="w-${compact ? '3' : '2'} h-${compact
|
||||
? '3'
|
||||
: '2'} rounded-full transition-transform duration-200 mt-0.5 ${this.hideExited
|
||||
? `translate-x-0.5 bg-dark-text-muted`
|
||||
: `translate-x-${compact ? '4' : '3'} bg-dark-bg`}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<button class="${buttonClass} text-status-warning" @click=${this.handleCleanExited}>
|
||||
Clean Exited (${exitedSessions.length})
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
${runningSessions.length > 0 && !this.killingAll
|
||||
? html`
|
||||
<button class="${buttonClass} text-status-error" @click=${this.handleKillAll}>
|
||||
Kill (${runningSessions.length})
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -200,7 +200,7 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
</style>
|
||||
<div
|
||||
class="relative w-full h-full overflow-hidden bg-black"
|
||||
style="view-transition-name: terminal-${this.sessionId}"
|
||||
style="view-transition-name: terminal-${this.sessionId}; min-height: 200px;"
|
||||
>
|
||||
${
|
||||
this.error
|
||||
|
|
@ -245,6 +245,14 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
html += `<div class="terminal-line" style="height: ${lineHeight}px; line-height: ${lineHeight}px;">${lineContent}</div>`;
|
||||
}
|
||||
|
||||
// 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 += `<div class="terminal-line" style="height: ${lineHeight}px; line-height: ${lineHeight}px;"> </div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Set innerHTML directly like terminal.ts does
|
||||
this.container.innerHTML = html;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
41
web/src/client/utils/constants.ts
Normal file
41
web/src/client/utils/constants.ts
Normal file
|
|
@ -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;
|
||||
104
web/src/client/utils/responsive-utils.ts
Normal file
104
web/src/client/utils/responsive-utils.ts
Normal file
|
|
@ -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();
|
||||
43
web/src/client/utils/terminal-utils.ts
Normal file
43
web/src/client/utils/terminal-utils.ts
Normal file
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue