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', () => {