From a5263b32527e8afef1acbbf9a4b303bbcb64f631 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 14 Jun 2026 15:49:12 -0400 Subject: [PATCH] Auto-wrap desktop session tabs to a second row on overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When desktop session tabs overflow one row, wrap them to a second row instead of horizontal scroll — unless the user has pinned the manual two-row layout (tabTwoRows). Mobile/tablet keep horizontal scroll. The wrap policy (shouldAutoWrapTabs) lives in constants.js as a pure, unit-testable function; updateTabOverflowMode() measures overflow after each tab render and toggles .tabs-auto-wrap. Test: test/tab-overflow.test.ts (vm-loads constants.js, asserts the policy). --- src/web/public/app.js | 33 ++++++++++++++++++++++++ src/web/public/constants.js | 15 +++++++++++ src/web/public/styles.css | 7 +++++ test/tab-overflow.test.ts | 51 +++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 test/tab-overflow.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index e5843288..56541be1 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2656,6 +2656,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() { 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/styles.css b/src/web/public/styles.css index 6ebbebc8..141b93c0 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..6c3a006d --- /dev/null +++ b/test/tab-overflow.test.ts @@ -0,0 +1,51 @@ +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); + }); +});