From e589004926ad10bdaaa0a75ead7f67abd8125871 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 04:08:07 +0200 Subject: [PATCH] fix(tabs): hide a case box the sidebar filter emptied (#580) Since the sidebar filter and the rail search share _applyTabListFilter(), the sidebar's filter box also marks a .tab-cluster it emptied in the by-case tab layout and rewrites its count. But the only rule hiding .tab-cluster.tab-filtered-out was the rail-scoped one, so in the sidebar the emptied box stayed painted with its label and a count of 0 while its rows were hidden. The sidebar rule now hides the marked case box as well. It stays scoped to html[data-session-list="sidebar"], so a leaked class still cannot hide anything on the header strip. An alerted row still counts toward its box (the owner's call on #580), so a box holding a row that needs the user never hides. Tests: a unit test renders the sidebar in the by-case layout and checks the emptied box is marked with a 0 count, that an alerted row keeps its box on screen and counted, and that clearing restores the totals; a stylesheet check pins the scoped selector; the browser test measures the shipped styles.css in Chromium: the marked box is not painted in the sidebar layout, an unmarked one is, and the header layout hides nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/styles.css | 8 +++-- test/tab-rail-search.browser.test.ts | 53 ++++++++++++++++++++++++---- test/tab-rail-search.test.ts | 50 ++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4b56ad5e..50229fdd 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -18814,8 +18814,12 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab.drag-over-right { Scoped to the sidebar layout on purpose: applySidebarFilter() already strips the class whenever the filter box is off screen, and this prefix is the second lock — a leaked class must never be able to hide tabs from the header - strip, which has no filter control to clear it with. */ -html[data-session-list="sidebar"] .session-tab.tab-filtered-out { + strip, which has no filter control to clear it with. The shared filter + (_applyTabListFilter) also marks a case box (tabArrangement 'case') the + filter emptied, which hides with its label and count rather than staying + on screen reading 0. */ +html[data-session-list="sidebar"] .session-tab.tab-filtered-out, +html[data-session-list="sidebar"] .tab-cluster.tab-filtered-out { display: none !important; } diff --git a/test/tab-rail-search.browser.test.ts b/test/tab-rail-search.browser.test.ts index f2ee4380..e135614c 100644 --- a/test/tab-rail-search.browser.test.ts +++ b/test/tab-rail-search.browser.test.ts @@ -2,12 +2,15 @@ * @fileoverview Real-Chromium coverage for the vertical rail's session search. * * What DOM emulation cannot answer: that the shipped CSS actually hides a - * filtered row and an emptied group, that the box shows only on the vertical - * rail, that the inline oninput/onkeydown/onclick handlers in index.html reach - * the app, and that a match inside a collapsed group can be clicked. The real - * #tabRail markup is lifted from index.html, and the shipping constants.js, - * tab-layout-browser.js, app.js, webview-tabs.js and styles.css are loaded - * into a page. + * filtered row and an emptied group (and, in the sidebar layout, an emptied + * case box), that the box shows only on the vertical rail, that the inline + * oninput/onkeydown/onclick handlers in index.html reach the app, that a match + * inside a collapsed group can be clicked, and that Escape in the box clears + * the search WITHOUT the global key handler (installed for real, capture + * phase, so it runs before the box's own onkeydown) also closing every panel. + * The real #tabRail markup is lifted from index.html, and the shipping + * constants.js, tab-layout-browser.js, app.js, webview-tabs.js and styles.css + * are loaded into a page. * * Port: none (page.route on a fake origin, no server). */ @@ -247,6 +250,44 @@ describe('vertical rail session search in Chromium', () => { expect(await page.evaluate(() => (window as any).__redraws)).toBe(1); }); + it('hides a case box the sidebar filter emptied, in the sidebar layout only', async () => { + const painted = await page.evaluate(() => { + const root = document.documentElement; + const probeHtml = + ''; + const measure = () => { + const probe = document.getElementById('sidebarProbe')!; + return { + emptied: probe.querySelector('[data-probe="emptied"]')!.getClientRects().length, + kept: probe.querySelector('[data-probe="kept"]')!.getClientRects().length, + }; + }; + root.setAttribute('data-tab-orientation', 'horizontal'); + document.body.insertAdjacentHTML('beforeend', probeHtml); + const probe = document.getElementById('sidebarProbe')!; + // Header layout (the box outside any sidebar, which that layout hides): + // the rule is scoped to the sidebar, so a leaked class hides nothing. + root.setAttribute('data-session-list', 'header'); + probe.classList.remove('session-sidebar'); + const header = measure(); + root.setAttribute('data-session-list', 'sidebar'); + probe.classList.add('session-sidebar'); + const sidebar = measure(); + document.getElementById('sidebarProbe')!.remove(); + root.removeAttribute('data-session-list'); + root.setAttribute('data-tab-orientation', 'vertical'); + return { header, sidebar }; + }); + expect(painted.sidebar).toEqual({ emptied: 0, kept: 1 }); + expect(painted.header.emptied).toBe(1); + }); + it('walks only the matches with the arrow keys', async () => { await page.getByRole('searchbox', { name: 'Search sessions' }).fill('a'); // 'a' matches Alpha API, Roadmap, API Review and Dashboard, not Notes. diff --git a/test/tab-rail-search.test.ts b/test/tab-rail-search.test.ts index cf42a78d..3dfdf12f 100644 --- a/test/tab-rail-search.test.ts +++ b/test/tab-rail-search.test.ts @@ -530,6 +530,56 @@ describe('the sidebar filter keeps its own matching', () => { expect(visibleRows()).toEqual(['roadmap']); expect(emptyNote().hidden).toBe(true); }); + + it('marks a case box the sidebar filter emptied, unless an alerted row keeps it', () => { + const app = makeApp({ tabLayout: null }); + document.documentElement.setAttribute('data-tab-orientation', 'horizontal'); + document.documentElement.dataset.tabArrangement = 'case'; + app.isSessionSidebarActive = () => true; + // alpha and notes share a folder, so they share a box. + app.sessions.get('notes').workingDir = '/srv/alpha'; + const box = (key: string) => document.querySelector(`.tab-cluster[data-cluster-key="${key}"]`)!; + try { + app._fullRenderSessionTabs(); + expect(box('/srv/alpha').querySelector('.tab-cluster-count')?.textContent).toBe('2'); + + app.applySidebarFilter('notes'); + expect(visibleRows()).toEqual(['notes']); + expect(box('/srv/alpha').classList.contains('tab-filtered-out')).toBe(false); + expect(box('/srv/alpha').querySelector('.tab-cluster-count')?.textContent).toBe('1'); + // The emptied box is marked (styles.css hides it in the sidebar too). + expect(box('/srv/api-plans').classList.contains('tab-filtered-out')).toBe(true); + expect(box('/srv/api-plans').querySelector('.tab-cluster-count')?.textContent).toBe('0'); + + // A row that needs the user keeps its box on screen, counted. + app.tabAlerts.set('roadmap', 'action'); + app._applyTabListFilter(); + // DOM order is box order: the /srv/alpha box (alpha, notes) comes first. + expect(visibleRows()).toEqual(['notes', 'roadmap']); + expect(box('/srv/api-plans').classList.contains('tab-filtered-out')).toBe(false); + expect(box('/srv/api-plans').querySelector('.tab-cluster-count')?.textContent).toBe('1'); + + app.applySidebarFilter(''); + expect(document.querySelectorAll('.tab-cluster.tab-filtered-out')).toHaveLength(0); + expect(box('/srv/alpha').querySelector('.tab-cluster-count')?.textContent).toBe('2'); + } finally { + delete document.documentElement.dataset.tabArrangement; + } + }); + + it('hides an emptied case box in the sidebar stylesheet, scoped to the sidebar layout', () => { + const css = read('styles.css'); + const rule = /([^{}]+)\{\s*display:\s*none\s*!important;\s*\}/g; + const selectorsHiding = [...css.matchAll(rule)].flatMap((match) => + match[1] + .replace(/\/\*[\s\S]*?\*\//g, '') + .split(',') + .map((selector) => selector.trim()) + ); + expect(selectorsHiding).toContain('html[data-session-list="sidebar"] .tab-cluster.tab-filtered-out'); + // Never unscoped: a leaked class must not hide a box on the header strip. + expect(selectorsHiding).not.toContain('.tab-cluster.tab-filtered-out'); + }); }); describe('Escape in the search box', () => {