diff --git a/src/web/public/app.js b/src/web/public/app.js index d2d31601..fc7e5278 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2665,6 +2665,39 @@ class CodemanApp { this._fullRenderSessionTabs(); } + this.updateTabOverflowMode(); + } + + // Auto-wrap desktop session tabs to a second row when they overflow one row, + // unless the user has pinned the manual two-row layout (tabTwoRows). Mobile/ + // tablet keep horizontal scroll. Policy lives in constants.js for unit testing. + updateTabOverflowMode() { + const container = this.$('sessionTabs'); + if (!container) return; + + const deviceType = MobileDetection.getDeviceType(); + const settings = this.loadAppSettingsFromStorage(); + const defaults = this.getDefaultSettings(); + const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false; + + if (manualTwoRows || deviceType !== 'desktop') { + container.classList.remove('tabs-auto-wrap'); + return; + } + + // Measure the natural one-row overflow, then enable wrapping only if needed. + container.classList.remove('tabs-auto-wrap'); + const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs + ? window.CodemanTabOverflow.shouldAutoWrapTabs({ + deviceType, + manualTwoRows, + tabCount: this.sessions.size, + scrollWidth: container.scrollWidth, + clientWidth: container.clientWidth, + }) + : container.scrollWidth > container.clientWidth + 1; + + container.classList.toggle('tabs-auto-wrap', shouldWrap); } _fullRenderSessionTabs() { @@ -2741,6 +2774,12 @@ class CodemanApp { // Update connection lines after tabs change (positions may have shifted) this.updateConnectionLines(); + + // Re-evaluate desktop auto-wrap for every full rebuild, including the incremental + // branch's early `_fullRenderSessionTabs(); return;` paths and the manual two-rows + // toggle (applyTabWrapSettings calls this) which would otherwise leave a stale + // tabs-auto-wrap class until the next content render. + this.updateTabOverflowMode(); } // Set up arrow key navigation for session tabs (accessibility) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index e375fafa..379b97c3 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -114,9 +114,24 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK // Expose for tests. `const` declarations at the top of a non-module script // are global lexical bindings but not `window` properties, so explicit // assignment is the test-visible API surface. +// Desktop tab-overflow policy: auto-wrap the session tabs to a second row when +// they overflow one row (and the user hasn't pinned the manual two-row layout). +function shouldAutoWrapTabs(input) { + if (!input || input.deviceType !== 'desktop') return false; + if (input.manualTwoRows) return false; + if ((input.tabCount || 0) < 2) return false; + + const scrollWidth = Number(input.scrollWidth) || 0; + const clientWidth = Number(input.clientWidth) || 0; + return scrollWidth > clientWidth + 1; +} + if (typeof window !== 'undefined') { window.WEBGL_FALLBACK = WEBGL_FALLBACK; window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip; + window.CodemanTabOverflow = { + shouldAutoWrapTabs, + }; } // Scheduler API — prioritize terminal writes over background UI updates. diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index a24fd569..f55bd2e1 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -139,6 +139,9 @@ const MobileDetection = { resizeTimeout = setTimeout(() => { this.updateBodyClass(); this.updateAppHeight(); + // Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only + // other trigger is a tab content render. No-op on mobile/tablet (method bails). + if (typeof app !== 'undefined') app.updateTabOverflowMode?.(); }, 100); }; window.addEventListener('resize', this._resizeHandler); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index d0861a4a..ba3f1622 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -312,6 +312,13 @@ body { max-height: 120px; } +.session-tabs.tabs-auto-wrap { + flex-wrap: wrap; + overflow-x: hidden; + overflow-y: auto; + max-height: 96px; +} + .session-tabs::-webkit-scrollbar { width: 4px; height: 0; diff --git a/test/tab-overflow.test.ts b/test/tab-overflow.test.ts new file mode 100644 index 00000000..0ed7895a --- /dev/null +++ b/test/tab-overflow.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +function loadTabOverflowHelper() { + const context = vm.createContext({ window: {}, globalThis: {} }); + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + vm.runInContext(source, context, { filename: 'constants.js' }); + return (context.window as { CodemanTabOverflow: { shouldAutoWrapTabs: (input: unknown) => boolean } }) + .CodemanTabOverflow; +} + +describe('tab overflow layout policy', () => { + it('auto-wraps desktop tabs when their rendered width exceeds available tab space', () => { + const helper = loadTabOverflowHelper(); + + expect( + helper.shouldAutoWrapTabs({ + deviceType: 'desktop', + manualTwoRows: false, + tabCount: 18, + scrollWidth: 1400, + clientWidth: 760, + }) + ).toBe(true); + }); + + it('does not auto-wrap when manual tall tabs are enabled or on mobile/tablet', () => { + const helper = loadTabOverflowHelper(); + + expect( + helper.shouldAutoWrapTabs({ + deviceType: 'desktop', + manualTwoRows: true, + tabCount: 18, + scrollWidth: 1400, + clientWidth: 760, + }) + ).toBe(false); + expect( + helper.shouldAutoWrapTabs({ + deviceType: 'mobile', + manualTwoRows: false, + tabCount: 18, + scrollWidth: 1400, + clientWidth: 320, + }) + ).toBe(false); + }); + + it('respects the boundary conditions (exact fit, +1 tolerance, and tabCount < 2)', () => { + const helper = loadTabOverflowHelper(); + const base = { deviceType: 'desktop' as const, manualTwoRows: false, tabCount: 6 }; + + // Exact fit: no overflow, no wrap. + expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 800, clientWidth: 800 })).toBe(false); + // Within the +1 sub-pixel tolerance: still no wrap. + expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 801, clientWidth: 800 })).toBe(false); + // 2px over: wrap. + expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 802, clientWidth: 800 })).toBe(true); + // A single overflowing tab must not wrap (need at least 2 to form a second row). + expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false); + }); +});