From a5263b32527e8afef1acbbf9a4b303bbcb64f631 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 14 Jun 2026 15:49:12 -0400 Subject: [PATCH 1/2] 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); + }); +}); From c7e8ff616f66ab5a71c53bbef8f02033c3c4f0a0 Mon Sep 17 00:00:00 2001 From: "Claude (Codeman maintainer)" Date: Sun, 14 Jun 2026 22:38:19 +0200 Subject: [PATCH 2/2] fix(tabs): re-evaluate auto-wrap on resize and on every full tab rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review polish on the desktop tab auto-wrap: - Auto-wrap is purely width-driven, but updateTabOverflowMode() was only called at the tail of _renderSessionTabsImmediate (SSE content renders). Window resize — the primary trigger for tabs crossing the one-row overflow threshold — never re-evaluated it, so narrowing/widening the window left the wrap state stale until an unrelated status event fired a render. Call it from the debounced window-resize handler (no-op on mobile/tablet, where the method bails). - Move the re-evaluation into _fullRenderSessionTabs() as well, so the incremental branch's two early `_fullRenderSessionTabs(); return;` paths (badge add/remove, which change tab width) and the manual two-rows toggle (applyTabWrapSettings → _fullRender…) re-evaluate too. The latter also fixes a transient where enabling manual two-rows while auto-wrap was on left both classes set (clipping folder tabs to 96px) until the next render. - Add boundary cases to the policy test: exact fit and the +1 sub-pixel tolerance (no wrap), 2px over (wrap), and a single overflowing tab (no wrap). Verified: tab-overflow test passes; tsc, check:frontend-syntax, check:public-assets, prettier all clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/web/public/app.js | 6 ++++++ src/web/public/mobile-handlers.js | 3 +++ test/tab-overflow.test.ts | 14 ++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/src/web/public/app.js b/src/web/public/app.js index 56541be1..8ef35206 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2765,6 +2765,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/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/test/tab-overflow.test.ts b/test/tab-overflow.test.ts index 6c3a006d..0ed7895a 100644 --- a/test/tab-overflow.test.ts +++ b/test/tab-overflow.test.ts @@ -48,4 +48,18 @@ describe('tab overflow layout policy', () => { }) ).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); + }); });