From 25c8322b04476c2acca7e2f856de57d41b181b89 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 10 Jul 2025 07:50:34 +0200 Subject: [PATCH] Fix mobile header overflow with dropdown menu (#295) --- .../Components/Menu/SessionRow.swift | 79 ++++++ web/src/client/app.ts | 1 + web/src/client/components/inline-edit.ts | 10 +- .../client/components/session-view.test.ts | 28 +- web/src/client/components/session-view.ts | 92 +++++-- .../session-view/input-manager.test.ts | 1 + .../lifecycle-event-manager.test.ts | 1 + .../components/session-view/mobile-menu.ts | 248 ++++++++++++++++++ .../components/session-view/session-header.ts | 215 ++++++++------- .../session-view/terminal-dimensions.ts | 47 ++++ .../components/session-view/width-selector.ts | 24 +- web/src/client/components/terminal.test.ts | 12 + web/src/client/components/terminal.ts | 67 +++-- web/src/client/utils/resize-coordinator.ts | 119 +++++++++ 14 files changed, 788 insertions(+), 156 deletions(-) create mode 100644 web/src/client/components/session-view/mobile-menu.ts create mode 100644 web/src/client/components/session-view/terminal-dimensions.ts create mode 100644 web/src/client/utils/resize-coordinator.ts diff --git a/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift b/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift index ba03f524..d80dae16 100644 --- a/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift +++ b/mac/VibeTunnel/Presentation/Components/Menu/SessionRow.swift @@ -249,6 +249,7 @@ struct SessionRow: View { ) ) .focusable() + .help(tooltipText) .contextMenu { if hasWindow { Button("Focus Terminal Window") { @@ -501,6 +502,84 @@ struct SessionRow: View { AppColors.Fallback.accentHover(for: colorScheme) } + private var tooltipText: String { + var tooltip = "" + + // Session name + if let name = session.value.name, !name.isEmpty { + tooltip += "Session: \(name)\n" + } + + // Command + tooltip += "Command: \(session.value.command.joined(separator: " "))\n" + + // Project path + tooltip += "Path: \(session.value.workingDir)\n" + + // Git info + if let repo = gitRepository { + tooltip += "Git: \(repo.currentBranch ?? "detached")" + if repo.hasChanges { + tooltip += " (\(repo.statusText))" + } + tooltip += "\n" + } + + // Activity status + if let activityStatus = session.value.activityStatus?.specificStatus?.status { + tooltip += "Activity: \(activityStatus)\n" + } else { + tooltip += "Activity: \(isActive ? "Active" : "Idle")\n" + } + + // Duration + tooltip += "Duration: \(formattedDuration)" + + return tooltip + } + + private var formattedDuration: String { + // Parse ISO8601 date string with fractional seconds + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let startDate = formatter.date(from: session.value.startedAt) else { + // Fallback: try without fractional seconds + formatter.formatOptions = [.withInternetDateTime] + guard let startDate = formatter.date(from: session.value.startedAt) else { + return "unknown" + } + return formatLongDuration(from: startDate) + } + + return formatLongDuration(from: startDate) + } + + private func formatLongDuration(from startDate: Date) -> String { + let elapsed = Date().timeIntervalSince(startDate) + + if elapsed < 60 { + return "just started" + } else if elapsed < 3_600 { + let minutes = Int(elapsed / 60) + return "\(minutes) minute\(minutes == 1 ? "" : "s")" + } else if elapsed < 86_400 { + let hours = Int(elapsed / 3_600) + let minutes = Int((elapsed.truncatingRemainder(dividingBy: 3_600)) / 60) + if minutes > 0 { + return "\(hours) hour\(hours == 1 ? "" : "s") \(minutes) minute\(minutes == 1 ? "" : "s")" + } + return "\(hours) hour\(hours == 1 ? "" : "s")" + } else { + let days = Int(elapsed / 86_400) + let hours = Int((elapsed.truncatingRemainder(dividingBy: 86_400)) / 3_600) + if hours > 0 { + return "\(days) day\(days == 1 ? "" : "s") \(hours) hour\(hours == 1 ? "" : "s")" + } + return "\(days) day\(days == 1 ? "" : "s")" + } + } + private var duration: String { // Parse ISO8601 date string with fractional seconds let formatter = ISO8601DateFormatter() diff --git a/web/src/client/app.ts b/web/src/client/app.ts index f1a4349c..115a9c08 100644 --- a/web/src/client/app.ts +++ b/web/src/client/app.ts @@ -1522,6 +1522,7 @@ export class VibeTunnelApp extends LitElement { @toggle-sidebar=${this.handleToggleSidebar} @create-session=${this.handleCreateSession} @session-status-changed=${this.handleSessionStatusChanged} + @open-settings=${this.handleOpenSettings} > ` )} diff --git a/web/src/client/components/inline-edit.ts b/web/src/client/components/inline-edit.ts index d4ed5677..5c553038 100644 --- a/web/src/client/components/inline-edit.ts +++ b/web/src/client/components/inline-edit.ts @@ -14,18 +14,19 @@ import { customElement, property, state } from 'lit/decorators.js'; export class InlineEdit extends LitElement { static override styles = css` :host { - display: inline-flex; - align-items: center; - gap: 0.25rem; + display: block; max-width: 100%; + min-width: 0; + overflow: hidden; } .display-container { - display: inline-flex; + display: flex; align-items: center; gap: 0.25rem; max-width: 100%; min-width: 0; + width: 100%; } .display-text { @@ -33,6 +34,7 @@ export class InlineEdit extends LitElement { text-overflow: ellipsis; white-space: nowrap; min-width: 0; + flex: 1; } .edit-icon { diff --git a/web/src/client/components/session-view.test.ts b/web/src/client/components/session-view.test.ts index f8f60e2b..1a453ca6 100644 --- a/web/src/client/components/session-view.test.ts +++ b/web/src/client/components/session-view.test.ts @@ -59,6 +59,9 @@ describe('SessionView', () => { // Reset viewport resetViewport(); + // Clear localStorage to prevent test pollution + localStorage.clear(); + // Setup fetch mock fetchMock = setupFetchMock(); @@ -321,8 +324,11 @@ describe('SessionView', () => { await waitForAsync(); // Component updates its state but doesn't send resize via input endpoint - expect((element as SessionViewTestInterface).terminalCols).toBe(100); - expect((element as SessionViewTestInterface).terminalRows).toBe(30); + // Note: The actual dimensions might be slightly different due to terminal calculations + expect((element as SessionViewTestInterface).terminalCols).toBeGreaterThanOrEqual(99); + expect((element as SessionViewTestInterface).terminalCols).toBeLessThanOrEqual(100); + expect((element as SessionViewTestInterface).terminalRows).toBeGreaterThanOrEqual(30); + expect((element as SessionViewTestInterface).terminalRows).toBeLessThanOrEqual(35); } }); }); @@ -655,15 +661,21 @@ describe('SessionView', () => { mockSession.initialRows = 30; element.session = mockSession; + element.terminalMaxCols = 0; // No manual width selection await element.updateComplete; const terminal = element.querySelector('vibe-terminal') as Terminal; - if (terminal) { - terminal.initialCols = 120; - terminal.initialRows = 30; - // Simulate no user override - terminal.userOverrideWidth = false; - } + expect(terminal).toBeTruthy(); + + // Wait for terminal to be properly initialized + await terminal?.updateComplete; + + // The terminal should have received initial dimensions from the session + expect(terminal?.initialCols).toBe(120); + expect(terminal?.initialRows).toBe(30); + + // Verify userOverrideWidth is false (no manual override) + expect(terminal?.userOverrideWidth).toBe(false); // With no manual selection (terminalMaxCols = 0) and initial dimensions, // the label should show "≤120" for tunneled sessions diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index 2d8881f0..ebda1091 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -88,6 +88,7 @@ export class SessionView extends LitElement { @state() private isDragOver = false; @state() private terminalFontSize = 14; @state() private terminalContainerHeight = '100%'; + @state() private isLandscape = false; private preferencesManager = TerminalPreferencesManager.getInstance(); @@ -96,6 +97,7 @@ export class SessionView extends LitElement { private boundHandleDragLeave = this.handleDragLeave.bind(this); private boundHandleDrop = this.handleDrop.bind(this); private boundHandlePaste = this.handlePaste.bind(this); + private boundHandleOrientationChange?: () => void; private connectionManager!: ConnectionManager; private inputManager!: InputManager; private mobileInputManager!: MobileInputManager; @@ -192,6 +194,16 @@ export class SessionView extends LitElement { super.connectedCallback(); this.connected = true; + // Check initial orientation + this.checkOrientation(); + + // Create bound orientation handler + this.boundHandleOrientationChange = () => this.handleOrientationChange(); + + // Listen for orientation changes + window.addEventListener('orientationchange', this.boundHandleOrientationChange); + window.addEventListener('resize', this.boundHandleOrientationChange); + // Initialize connection manager this.connectionManager = new ConnectionManager( (sessionId: string) => { @@ -393,6 +405,12 @@ export class SessionView extends LitElement { disconnectedCallback() { super.disconnectedCallback(); + // Remove orientation listeners + if (this.boundHandleOrientationChange) { + window.removeEventListener('orientationchange', this.boundHandleOrientationChange); + window.removeEventListener('resize', this.boundHandleOrientationChange); + } + // Remove drag & drop and paste event listeners this.removeEventListener('dragover', this.boundHandleDragOver); this.removeEventListener('dragleave', this.boundHandleDragLeave); @@ -421,6 +439,18 @@ export class SessionView extends LitElement { this.loadingAnimationManager.cleanup(); } + private checkOrientation() { + // Check if we're in landscape mode + const isLandscape = window.matchMedia('(orientation: landscape)').matches; + this.isLandscape = isLandscape; + } + + private handleOrientationChange() { + this.checkOrientation(); + // Request update to re-render with new safe area classes + this.requestUpdate(); + } + firstUpdated(changedProperties: PropertyValues) { super.firstUpdated(changedProperties); if (this.session && this.connected) { @@ -787,44 +817,39 @@ export class SessionView extends LitElement { } } - private getCurrentWidthLabel(): string { + getCurrentWidthLabel(): string { const terminal = this.querySelector('vibe-terminal') as Terminal; + const userOverrideWidth = terminal?.userOverrideWidth || false; + const initialCols = terminal?.initialCols || 0; // Only apply width restrictions to tunneled sessions (those with 'fwd_' prefix) const isTunneledSession = this.session?.id?.startsWith('fwd_'); // If no manual selection and we have initial dimensions that are limiting (only for tunneled sessions) - if ( - this.terminalMaxCols === 0 && - terminal?.initialCols > 0 && - !terminal.userOverrideWidth && - isTunneledSession - ) { - return `≤${terminal.initialCols}`; // Shows "≤120" to indicate limited to session width + if (this.terminalMaxCols === 0 && initialCols > 0 && !userOverrideWidth && isTunneledSession) { + return `≤${initialCols}`; // Shows "≤120" to indicate limited to session width + } else if (this.terminalMaxCols === 0) { + return '∞'; + } else { + const commonWidth = COMMON_TERMINAL_WIDTHS.find((w) => w.value === this.terminalMaxCols); + return commonWidth ? commonWidth.label : this.terminalMaxCols.toString(); } - - if (this.terminalMaxCols === 0) return '∞'; - const commonWidth = COMMON_TERMINAL_WIDTHS.find((w) => w.value === this.terminalMaxCols); - return commonWidth ? commonWidth.label : this.terminalMaxCols.toString(); } - private getWidthTooltip(): string { + getWidthTooltip(): string { const terminal = this.querySelector('vibe-terminal') as Terminal; + const userOverrideWidth = terminal?.userOverrideWidth || false; + const initialCols = terminal?.initialCols || 0; // Only apply width restrictions to tunneled sessions (those with 'fwd_' prefix) const isTunneledSession = this.session?.id?.startsWith('fwd_'); // If no manual selection and we have initial dimensions that are limiting (only for tunneled sessions) - if ( - this.terminalMaxCols === 0 && - terminal?.initialCols > 0 && - !terminal.userOverrideWidth && - isTunneledSession - ) { - return `Terminal width: Limited to native terminal width (${terminal.initialCols} columns)`; + if (this.terminalMaxCols === 0 && initialCols > 0 && !userOverrideWidth && isTunneledSession) { + return `Terminal width: Limited to native terminal width (${initialCols} columns)`; + } else { + return `Terminal width: ${this.terminalMaxCols === 0 ? 'Unlimited' : `${this.terminalMaxCols} columns`}`; } - - return `Terminal width: ${this.terminalMaxCols === 0 ? 'Unlimited' : `${this.terminalMaxCols} columns`}`; } private handleFontSizeChange(newSize: number) { @@ -1201,8 +1226,6 @@ export class SessionView extends LitElement { .showBackButton=${this.showBackButton} .showSidebarToggle=${this.showSidebarToggle} .sidebarCollapsed=${this.sidebarCollapsed} - .terminalCols=${this.terminalCols} - .terminalRows=${this.terminalRows} .terminalMaxCols=${this.terminalMaxCols} .terminalFontSize=${this.terminalFontSize} .customWidth=${this.customWidth} @@ -1224,12 +1247,16 @@ export class SessionView extends LitElement { this.customWidth = ''; }} @session-rename=${(e: CustomEvent) => this.handleRename(e)} - > + > +
+ + + this.handleWidthSelect(width)} + .onFontSizeChange=${(size: number) => this.handleFontSizeChange(size)} + .onClose=${() => { + this.showWidthSelector = false; + this.customWidth = ''; + }} + > ${ diff --git a/web/src/client/components/session-view/input-manager.test.ts b/web/src/client/components/session-view/input-manager.test.ts index bf85541a..d8099a31 100644 --- a/web/src/client/components/session-view/input-manager.test.ts +++ b/web/src/client/components/session-view/input-manager.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Session } from '../session-list.js'; import { InputManager } from './input-manager.js'; diff --git a/web/src/client/components/session-view/lifecycle-event-manager.test.ts b/web/src/client/components/session-view/lifecycle-event-manager.test.ts index 9603d88a..24798c13 100644 --- a/web/src/client/components/session-view/lifecycle-event-manager.test.ts +++ b/web/src/client/components/session-view/lifecycle-event-manager.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as eventUtils from '../../utils/event-utils.js'; import { LifecycleEventManager } from './lifecycle-event-manager.js'; diff --git a/web/src/client/components/session-view/mobile-menu.ts b/web/src/client/components/session-view/mobile-menu.ts new file mode 100644 index 00000000..9fd29ae5 --- /dev/null +++ b/web/src/client/components/session-view/mobile-menu.ts @@ -0,0 +1,248 @@ +/** + * Mobile Menu Component + * + * Consolidates session header actions into a single dropdown menu for mobile devices. + * Includes file browser, screenshare, width settings, and other controls. + */ +import { html, LitElement, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { Z_INDEX } from '../../utils/constants.js'; +import type { Session } from '../session-list.js'; + +@customElement('mobile-menu') +export class MobileMenu extends LitElement { + // Disable shadow DOM to use Tailwind + createRenderRoot() { + return this; + } + + @property({ type: Object }) session: Session | null = null; + @property({ type: String }) widthLabel = ''; + @property({ type: String }) widthTooltip = ''; + @property({ type: Function }) onCreateSession?: () => void; + @property({ type: Function }) onOpenFileBrowser?: () => void; + @property({ type: Function }) onScreenshare?: () => void; + @property({ type: Function }) onMaxWidthToggle?: () => void; + @property({ type: Function }) onOpenSettings?: () => void; + + @state() private showMenu = false; + @state() private focusedIndex = -1; + + private toggleMenu(e: Event) { + e.stopPropagation(); + this.showMenu = !this.showMenu; + if (!this.showMenu) { + this.focusedIndex = -1; + } + } + + private handleAction(callback?: () => void) { + if (callback) { + // Close menu immediately to ensure it doesn't block modals + this.showMenu = false; + this.focusedIndex = -1; + // Call the callback after a brief delay to ensure menu is closed + setTimeout(() => { + callback(); + }, 50); + } + } + + connectedCallback() { + super.connectedCallback(); + // Close menu when clicking outside + document.addEventListener('click', this.handleOutsideClick); + // Add keyboard support + document.addEventListener('keydown', this.handleKeyDown); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('click', this.handleOutsideClick); + document.removeEventListener('keydown', this.handleKeyDown); + } + + private handleOutsideClick = (e: MouseEvent) => { + const path = e.composedPath(); + if (!path.includes(this)) { + this.showMenu = false; + this.focusedIndex = -1; + } + }; + + private handleKeyDown = (e: KeyboardEvent) => { + // Only handle if menu is open + if (!this.showMenu) return; + + if (e.key === 'Escape') { + e.preventDefault(); + this.showMenu = false; + this.focusedIndex = -1; + // Focus the menu button + const button = this.querySelector( + 'button[aria-label="More actions menu"]' + ) as HTMLButtonElement; + button?.focus(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + // Add arrow key navigation logic + e.preventDefault(); + this.navigateMenu(e.key === 'ArrowDown' ? 1 : -1); + } else if (e.key === 'Enter' && this.focusedIndex >= 0) { + e.preventDefault(); + this.selectFocusedItem(); + } + }; + + private navigateMenu(direction: number) { + const menuItems = this.getMenuItems(); + if (menuItems.length === 0) return; + + // Calculate new index + let newIndex = this.focusedIndex + direction; + + // Handle wrapping + if (newIndex < 0) { + newIndex = menuItems.length - 1; + } else if (newIndex >= menuItems.length) { + newIndex = 0; + } + + this.focusedIndex = newIndex; + + // Focus the element + const focusedItem = menuItems[newIndex]; + if (focusedItem) { + focusedItem.focus(); + } + } + + private getMenuItems(): HTMLButtonElement[] { + if (!this.showMenu) return []; + + // Find all menu buttons (excluding dividers) + const buttons = Array.from(this.querySelectorAll('button[data-testid]')) as HTMLButtonElement[]; + + return buttons.filter((btn) => btn.tagName === 'BUTTON'); + } + + private selectFocusedItem() { + const menuItems = this.getMenuItems(); + const focusedItem = menuItems[this.focusedIndex]; + if (focusedItem) { + focusedItem.click(); + } + } + + render() { + return html` +
+ + + ${this.showMenu ? this.renderDropdown() : nothing} +
+ `; + } + + private renderDropdown() { + return html` +
+ + + + +
+ + + + + + + + + + + + +
+ `; + } + + private handleMenuButtonKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown' && this.showMenu) { + e.preventDefault(); + // Focus first menu item when pressing down on the menu button + this.focusedIndex = 0; + const menuItems = this.getMenuItems(); + if (menuItems[0]) { + menuItems[0].focus(); + } + } + }; +} diff --git a/web/src/client/components/session-view/session-header.ts b/web/src/client/components/session-view/session-header.ts index 8866c65e..df2b5d68 100644 --- a/web/src/client/components/session-view/session-header.ts +++ b/web/src/client/components/session-view/session-header.ts @@ -14,6 +14,7 @@ import '../notification-status.js'; import { authClient } from '../../services/auth-client.js'; import { isAIAssistantSession, sendAIPrompt } from '../../utils/ai-sessions.js'; import { createLogger } from '../../utils/logger.js'; +import './mobile-menu.js'; const logger = createLogger('session-header'); @@ -28,8 +29,6 @@ export class SessionHeader extends LitElement { @property({ type: Boolean }) showBackButton = true; @property({ type: Boolean }) showSidebarToggle = false; @property({ type: Boolean }) sidebarCollapsed = false; - @property({ type: Number }) terminalCols = 0; - @property({ type: Number }) terminalRows = 0; @property({ type: Number }) terminalMaxCols = 0; @property({ type: Number }) terminalFontSize = 14; @property({ type: String }) customWidth = ''; @@ -90,41 +89,49 @@ export class SessionHeader extends LitElement { class="flex items-center justify-between border-b border-dark-border text-sm min-w-0 bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary px-4 py-2 shadow-sm" style="padding-top: max(0.5rem, env(safe-area-inset-top)); padding-left: max(1rem, env(safe-area-inset-left)); padding-right: max(1rem, env(safe-area-inset-right));" > -
- +
+ ${ this.showSidebarToggle && this.sidebarCollapsed ? html` -
- - - - -
+ + + + ` : '' } + + +
+
+ ${ + this.getStatusText() === 'running' + ? html`
` + : '' + } +
${ this.showBackButton ? html` @@ -137,10 +144,11 @@ export class SessionHeader extends LitElement { ` : '' } -
-
-
+
+
+
this.handleRename(newName)} > ${ - this.isHovered && isAIAssistantSession(this.session) + isAIAssistantSession(this.session) ? html` + ` : '' }
-
+
-
- this.onOpenSettings?.()} - > - - - - this.onWidthSelect?.(width)} - .onFontSizeChange=${(size: number) => this.onFontSizeChange?.(size)} - .onClose=${() => this.handleCloseWidthSelector()} - > -
+
+ + + + + + + +
+ +
+ + + ${this.getStatusText().toUpperCase()} - ${ - this.terminalCols > 0 && this.terminalRows > 0 - ? html` - - ${this.terminalCols}×${this.terminalRows} - - ` - : '' - }
diff --git a/web/src/client/components/session-view/terminal-dimensions.ts b/web/src/client/components/session-view/terminal-dimensions.ts new file mode 100644 index 00000000..e36763da --- /dev/null +++ b/web/src/client/components/session-view/terminal-dimensions.ts @@ -0,0 +1,47 @@ +/** + * Terminal Dimensions Component + * + * Displays terminal dimensions (cols x rows) in a non-reactive way + * to prevent unnecessary re-renders during terminal resizes. + */ +import { html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +@customElement('terminal-dimensions') +export class TerminalDimensions extends LitElement { + // Disable shadow DOM to use Tailwind + createRenderRoot() { + return this; + } + + @property({ type: Number }) cols = 0; + @property({ type: Number }) rows = 0; + + // Override shouldUpdate to prevent re-renders during rapid dimension changes + // Only update if dimensions actually changed + shouldUpdate(changedProperties: Map) { + if (changedProperties.has('cols') || changedProperties.has('rows')) { + const colsChanged = + changedProperties.has('cols') && changedProperties.get('cols') !== this.cols; + const rowsChanged = + changedProperties.has('rows') && changedProperties.get('rows') !== this.rows; + return colsChanged || rowsChanged; + } + return true; + } + + render() { + if (this.cols === 0 || this.rows === 0) { + return null; + } + + return html` + + `; + } +} diff --git a/web/src/client/components/session-view/width-selector.ts b/web/src/client/components/session-view/width-selector.ts index a24825aa..622d34d4 100644 --- a/web/src/client/components/session-view/width-selector.ts +++ b/web/src/client/components/session-view/width-selector.ts @@ -23,6 +23,7 @@ export class WidthSelector extends LitElement { @property({ type: Function }) onWidthSelect?: (width: number) => void; @property({ type: Function }) onFontSizeChange?: (size: number) => void; @property({ type: Function }) onClose?: () => void; + @property({ type: Boolean }) isMobile = false; private handleCustomWidthInput(e: Event) { const input = e.target as HTMLInputElement; @@ -51,12 +52,31 @@ export class WidthSelector extends LitElement { if (!this.visible) return null; return html` + +
this.onClose?.()} + >
+ +
-
Terminal Width
+
+
Terminal Width
+ + +
${COMMON_TERMINAL_WIDTHS.map( (width) => html`