Files
Codeman/test/session-list-layout.test.ts
T
Claudia[bot] c01edcbbb8 feat(web): optional collapsible left session sidebar
The header tab strip stops working past roughly a dozen sessions: it wraps
into two or three rows, eats vertical space and still cannot be scanned.
This adds a vertical session list in a left <aside> as an ALTERNATIVE
layout — a filter box, a live count, and a 44px collapsed rail that keeps
the ambient signal (status dot, task badge) visible.

The strip is not removed. Settings -> Display -> Tab Bar -> Session List
Layout switches between them and the default stays 'header', so existing
users see no change until they opt in.

Structure: one #sessionTabs element, two mount points. applySessionListLayout()
re-parents the SAME node between #sessionTabsHost and #sessionSidebarList,
which is why there is no second renderer and no duplicated wiring — app.$()
caches getElementById results and never invalidates them, so a moved node
keeps every existing consumer (settings-ui, webview-tabs, the generated
gesture bundle, the mobile tests) working untouched.

Notable integration points:
- Below 1024px the sidebar is an off-canvas drawer overlaying the terminal;
  closed it gets inert + aria-hidden so it cannot be tabbed into, and touch
  swipes over it no longer switch sessions.
- Subagent and ultracode windows anchor to the right edge of a sidebar row
  instead of its bottom, connector curves follow.
- Alt+B toggles; the chord is gated out of the PTY so xterm cannot also
  write ESC b into a live session.
- Collapse state lives in its own localStorage key (the settings blob is
  rebuilt from DOM controls on every save) and falls back to in-memory
  intent where storage throws.

Verified: frontend syntax + public asset checks, tsc, eslint, 26 new jsdom
tests, and a headless-Chromium harness (scripts/verify-session-sidebar.mts)
that renders a synthetic 25-session fleet in both layouts at 1600/1000/393px
and asserts mount point, widths, inert/aria state and row count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:42:21 +02:00

504 lines
24 KiB
TypeScript

/**
* @fileoverview Session list layout: header tab strip ⟷ collapsible left sidebar.
*
* The whole design rests on ONE invariant: there is exactly one `#sessionTabs`
* element and `applySessionListLayout()` RE-PARENTS it between the header host
* and the sidebar. It must never be cloned or rebuilt — `app.$(id)` caches
* elements by id and never invalidates, and settings-ui.js / webview-tabs.js
* resolve the same id independently, so a rebuilt container would leave every
* consumer writing into a detached orphan, silently and without an error.
* `keeps the same DOM node across a layout flip` below is therefore the single
* most important assertion in this file.
*
* Builds a JSDOM window in-test under the default node env, same shape as
* test/webview-menu-rows.test.ts. Do NOT declare a per-file jsdom environment:
* it externalizes node:fs under vite and the readFileSync calls below stop
* working. ⚠ Do not name that directive in a comment either, vitest matches the
* string anywhere in the file.
*/
import { readFileSync } from 'node:fs';
import { describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const APP = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf-8');
const SETTINGS_UI = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
const MOBILE_CSS = readFileSync(new URL('../src/web/public/mobile.css', import.meta.url), 'utf-8');
const I18N = readFileSync(new URL('../src/web/public/i18n.js', import.meta.url), 'utf-8');
const TERMINAL_UI = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf-8');
const MOBILE_HANDLERS = readFileSync(new URL('../src/web/public/mobile-handlers.js', import.meta.url), 'utf-8');
const SCHEMAS = readFileSync(new URL('../src/web/schemas.ts', import.meta.url), 'utf-8');
interface LayoutApp {
soloSessionId: string | null;
sessions: Map<string, unknown>;
sessionOrder: string[];
_tallTabsEnabled?: boolean;
_sidebarFilter?: string;
_elemCache: Map<string, unknown>;
$(id: string): Element | null;
getSessionListLayout(): string;
isSessionSidebarActive(): boolean;
isSessionSidebarCollapsed(): boolean;
applySessionListLayout(): void;
toggleSessionSidebar(): void;
updateSidebarCount(): void;
closeSessionSidebarOnHandheld(): void;
_isSessionSidebarOverlay(): boolean;
applySidebarFilter(query?: string): void;
_fullRenderSessionTabs(): void;
updateConnectionLines(): void;
}
/** The parts of index.html this feature touches, minus everything it does not. */
const SHELL = `
<header class="header">
<div class="header-brand">
<span class="logo">Codeman</span>
<button class="btn-icon-header btn-sidebar-toggle btn-sidebar-toggle--hidden"
id="sidebarToggleBtn" aria-expanded="true" aria-controls="sessionSidebar"
title="Collapse session sidebar" aria-label="Collapse session sidebar"></button>
</div>
<div class="session-tabs-host" id="sessionTabsHost">
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs" aria-orientation="horizontal"></div>
</div>
</header>
<main class="main">
<aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions">
<div class="session-sidebar-head">
<span class="session-sidebar-title">Sessions</span>
<span class="session-sidebar-count" id="sessionSidebarCount"></span>
</div>
<div class="session-sidebar-filter">
<input type="search" id="sessionSidebarFilter" class="session-sidebar-filter-input">
</div>
<div class="session-sidebar-list" id="sessionSidebarList"></div>
</aside>
<div class="terminal-wrap"></div>
</main>
`;
function boot(
options: {
stored?: Record<string, unknown>;
solo?: string | null;
deviceType?: string;
viewportWidth?: number;
} = {}
) {
const dom = new JSDOM(`<!doctype html><html><body>${SHELL}</body></html>`, {
url: 'http://localhost/',
runScripts: 'outside-only',
});
const win = dom.window as unknown as Window & typeof globalThis & { __CodemanApp: new () => LayoutApp };
// Whether the sidebar is a docked column or a modal overlay is decided by
// WIDTH (< 1024px), not by MobileDetection.getDeviceType() — that one calls
// everything from 768px up 'desktop' while mobile.css, which defines the
// overlay, is loaded with media="(max-width: 1023px)". jsdom defaults to
// exactly 1024, so every handheld case has to say so explicitly.
const width = options.viewportWidth ?? ((options.deviceType ?? 'desktop') === 'desktop' ? 1440 : 393);
Object.defineProperty(win, 'innerWidth', { value: width, configurable: true, writable: true });
// Handhelds read a separate settings blob (getSettingsStorageKey), so a
// handheld harness must seed the handheld key or the layout silently stays
// on the header strip.
const settingsKey =
(options.deviceType ?? 'desktop') === 'desktop' ? 'codeman-app-settings' : 'codeman-app-settings-mobile';
if (options.stored) {
win.localStorage.setItem(settingsKey, JSON.stringify(options.stored));
}
// app.js assigns window.MobileDetection at top level from the global that
// mobile-handlers.js declares, so it has to exist before the source runs.
// One eval, not three: `class CodemanApp` is a lexical binding and would not
// survive into a second global eval, and settings-ui.js needs it at load time.
(win as unknown as { eval: (s: string) => void }).eval(
[
`var MobileDetection = {
getDeviceType: () => ${JSON.stringify(options.deviceType ?? 'desktop')},
isHandheldDevice: () => ${JSON.stringify(options.deviceType ?? 'desktop')} !== 'desktop',
isMobile: () => false,
isTouchDevice: () => false,
};`,
CONSTANTS,
APP,
SETTINGS_UI,
'window.__CodemanApp = CodemanApp;',
].join('\n')
);
// Object.create, not `new`: the constructor boots SSE, timers and the whole
// terminal stack. Only the layout surface is under test here.
const app = Object.create(win.__CodemanApp.prototype) as LayoutApp;
app.soloSessionId = options.solo ?? null;
app.sessions = new Map();
app.sessionOrder = [];
app._elemCache = new Map();
app._fullRenderSessionTabs = vi.fn();
app.updateConnectionLines = vi.fn();
return { dom, win, app };
}
const tabsEl = (win: Window) => win.document.getElementById('sessionTabs')!;
const toggleBtn = (win: Window) => win.document.getElementById('sidebarToggleBtn')!;
describe('session list layout', () => {
it('defaults to the header tab strip when nothing is stored', () => {
const { win, app } = boot();
expect(app.getSessionListLayout()).toBe('header');
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sessionList).toBe('header');
expect(app.isSessionSidebarActive()).toBe(false);
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true);
});
it('re-parents the tab list into the sidebar and flips the a11y state', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
expect(app.getSessionListLayout()).toBe('sidebar');
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sessionList).toBe('sidebar');
expect(win.document.documentElement.dataset.sidebar).toBe('expanded');
expect(app.isSessionSidebarActive()).toBe(true);
expect(tabsEl(win).parentElement?.id).toBe('sessionSidebarList');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(false);
expect(toggleBtn(win).getAttribute('aria-expanded')).toBe('true');
});
it('keeps the same DOM node across a layout flip (the $() element cache never invalidates)', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
const original = tabsEl(win);
// Seed the cache the way any real render would.
expect(app.$('sessionTabs')).toBe(original);
app.applySessionListLayout();
expect(tabsEl(win)).toBe(original);
expect(app.$('sessionTabs')).toBe(original);
expect(original.parentElement?.id).toBe('sessionSidebarList');
// …and back again.
win.localStorage.setItem('codeman-app-settings', JSON.stringify({ sessionListLayout: 'header' }));
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applySessionListLayout();
expect(tabsEl(win)).toBe(original);
expect(app.$('sessionTabs')).toBe(original);
expect(original.parentElement?.id).toBe('sessionTabsHost');
expect(original.getAttribute('aria-orientation')).toBe('horizontal');
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true);
});
it('never selects the sidebar in a solo (detached) window', () => {
// A solo window shows one session, so the list is noise — and #sessionTabs
// parked in the display:none <aside> would measure 0/0 for tab overflow and
// the inline rename input.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' }, solo: 'sess-1' });
expect(app.getSessionListLayout()).toBe('header');
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sessionList).toBe('header');
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
});
it('round-trips the collapse state through its own storage key', () => {
// Deliberately NOT in the app-settings blob: saveAppSettings() rebuilds that
// blob from the DOM controls, so a key without a control is wiped on Save.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
app.applySessionListLayout();
const aside = win.document.getElementById('sessionSidebar')!;
expect(aside.classList.contains('open')).toBe(true);
app.toggleSessionSidebar();
expect(win.localStorage.getItem('codeman-sidebar-collapsed')).toBe('1');
expect(win.document.documentElement.dataset.sidebar).toBe('collapsed');
expect(toggleBtn(win).getAttribute('aria-expanded')).toBe('false');
expect(toggleBtn(win).getAttribute('aria-label')).toBe('Expand session sidebar');
expect(aside.classList.contains('open')).toBe(false);
app.toggleSessionSidebar();
expect(win.localStorage.getItem('codeman-sidebar-collapsed')).toBe('0');
expect(win.document.documentElement.dataset.sidebar).toBe('expanded');
expect(toggleBtn(win).getAttribute('aria-expanded')).toBe('true');
expect(toggleBtn(win).getAttribute('aria-label')).toBe('Collapse session sidebar');
expect(aside.classList.contains('open')).toBe(true);
});
it('starts the handheld drawer CLOSED when the user has made no choice yet', () => {
// Below 1024px the sidebar is an off-canvas overlay, so "expanded" on a cold
// load would mean a drawer sitting on top of the terminal every time.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' }, deviceType: 'mobile' });
app.applySessionListLayout();
expect(app.isSessionSidebarActive()).toBe(true);
expect(app.isSessionSidebarCollapsed()).toBe(true);
expect(win.document.documentElement.dataset.sidebar).toBe('collapsed');
expect(win.document.getElementById('sessionSidebar')?.classList.contains('open')).toBe(false);
// An explicit choice still wins over the device default.
win.localStorage.setItem('codeman-sidebar-collapsed', '0');
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sidebar).toBe('expanded');
});
it('dismisses the handheld drawer on selection but never the docked desktop sidebar', () => {
const handheld = boot({ stored: { sessionListLayout: 'sidebar' }, deviceType: 'mobile' });
handheld.win.localStorage.setItem('codeman-sidebar-collapsed', '0');
handheld.app.applySessionListLayout();
handheld.app.closeSessionSidebarOnHandheld();
expect(handheld.win.document.documentElement.dataset.sidebar).toBe('collapsed');
const desktop = boot({ stored: { sessionListLayout: 'sidebar' } });
desktop.app.applySessionListLayout();
desktop.app.closeSessionSidebarOnHandheld();
expect(desktop.win.document.documentElement.dataset.sidebar).toBe('expanded');
});
it('does nothing on toggle while the header strip is active', () => {
const { win, app } = boot();
app.applySessionListLayout();
app.toggleSessionSidebar();
expect(win.localStorage.getItem('codeman-sidebar-collapsed')).toBeNull();
expect(win.document.documentElement.dataset.sidebar).toBe('expanded');
});
it('filters rows by rendered name and working directory without re-rendering', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
app.applySessionListLayout();
tabsEl(win).innerHTML = `
<div class="session-tab" data-id="a" aria-label="api server" title="/srv/api"></div>
<div class="session-tab" data-id="b" aria-label="docs" title="/home/docs"></div>
<div class="session-tab session-tab--web" data-webview-id="w" aria-label="Grafana web tab" title="http://x/g"></div>
`;
const before = tabsEl(win).querySelectorAll('.session-tab');
app.applySidebarFilter('api');
expect(
[...tabsEl(win).querySelectorAll('.session-tab')].map((t) => t.classList.contains('tab-filtered-out'))
).toEqual([false, true, true]);
// Pure class toggling — no node was replaced.
expect(tabsEl(win).querySelectorAll('.session-tab')[0]).toBe(before[0]);
app.applySidebarFilter('/home');
expect(tabsEl(win).querySelectorAll('.session-tab')[1].classList.contains('tab-filtered-out')).toBe(false);
app.applySidebarFilter('');
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(0);
});
it('drops the filter when the list moves back to the header strip', () => {
// The filter <input> lives inside the sidebar, so a filter surviving a
// layout flip would hide sessions from the header tab strip with no
// reachable control to clear it — and every SSE-driven re-render re-hides
// them, so only a reload recovers.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
app.applySessionListLayout();
tabsEl(win).innerHTML = `
<div class="session-tab" data-id="a" aria-label="api server" title="/srv/api"></div>
<div class="session-tab" data-id="b" aria-label="docs" title="/home/docs"></div>
`;
const filterInput = win.document.getElementById('sessionSidebarFilter') as HTMLInputElement;
filterInput.value = 'api';
app.applySidebarFilter('api');
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(1);
win.localStorage.setItem('codeman-app-settings', JSON.stringify({ sessionListLayout: 'header' }));
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applySessionListLayout();
expect(app._sidebarFilter).toBe('');
expect(filterInput.value).toBe('');
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(0);
});
it('suspends the filter while the rail is collapsed and restores it on expand', () => {
// Collapsing hides .session-sidebar-filter, so a filter left applied would
// show 3 of 25 status dots in the rail with no visible cause.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
app.applySessionListLayout();
tabsEl(win).innerHTML = `
<div class="session-tab" data-id="a" aria-label="api server" title="/srv/api"></div>
<div class="session-tab" data-id="b" aria-label="docs" title="/home/docs"></div>
`;
app.applySidebarFilter('api');
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(1);
app.toggleSessionSidebar();
expect(win.document.documentElement.dataset.sidebar).toBe('collapsed');
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(0);
expect(app._sidebarFilter).toBe('api');
app.toggleSessionSidebar();
expect(tabsEl(win).querySelectorAll('.tab-filtered-out')).toHaveLength(1);
});
it('treats the 768-1023px band as an overlay, matching mobile.css', () => {
// getDeviceType() calls 900px 'desktop', but mobile.css — which defines the
// off-canvas overlay — is loaded with media="(max-width: 1023px)". Using the
// device type here gave that band overlay CSS with docked-sidebar logic: the
// drawer opened itself on load and neither selection nor Escape closed it.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' }, viewportWidth: 900 });
expect(app._isSessionSidebarOverlay()).toBe(true);
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sidebar).toBe('collapsed');
win.localStorage.setItem('codeman-sidebar-collapsed', '0');
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sidebar).toBe('expanded');
app.closeSessionSidebarOnHandheld();
expect(win.document.documentElement.dataset.sidebar).toBe('collapsed');
});
it('makes a closed overlay drawer inert, but never the docked desktop rail', () => {
// translateX(-100%) alone leaves the filter box and ~4 tab stops per session
// in the Tab order and in the accessibility tree.
const overlay = boot({ stored: { sessionListLayout: 'sidebar' }, viewportWidth: 900 });
overlay.app.applySessionListLayout();
const drawer = overlay.win.document.getElementById('sessionSidebar')!;
expect(drawer.hasAttribute('inert')).toBe(true);
expect(drawer.getAttribute('aria-hidden')).toBe('true');
overlay.app.toggleSessionSidebar();
expect(drawer.hasAttribute('inert')).toBe(false);
expect(drawer.hasAttribute('aria-hidden')).toBe(false);
const desktop = boot({ stored: { sessionListLayout: 'sidebar' } });
desktop.win.localStorage.setItem('codeman-sidebar-collapsed', '1');
desktop.app.applySessionListLayout();
const rail = desktop.win.document.getElementById('sessionSidebar')!;
expect(desktop.win.document.documentElement.dataset.sidebar).toBe('collapsed');
expect(rail.hasAttribute('inert')).toBe(false);
});
it('steals focus only for the modal drawer, never for the docked sidebar', () => {
// The docked sidebar is chrome, not a dialog: pulling the caret out of the
// terminal mid-prompt swallows everything typed after, because .session-tab
// handles only arrows/Home/End/Enter/Space.
const rows = `<div class="session-tab active" data-id="a" tabindex="0" aria-label="api"></div>`;
const desktop = boot({ stored: { sessionListLayout: 'sidebar' } });
desktop.win.localStorage.setItem('codeman-sidebar-collapsed', '1');
desktop.app.applySessionListLayout();
tabsEl(desktop.win).innerHTML = rows;
desktop.app.toggleSessionSidebar();
expect(desktop.win.document.activeElement).toBe(desktop.win.document.body);
const drawer = boot({ stored: { sessionListLayout: 'sidebar' }, viewportWidth: 900 });
drawer.app.applySessionListLayout();
tabsEl(drawer.win).innerHTML = rows;
drawer.app.toggleSessionSidebar();
expect((drawer.win.document.activeElement as HTMLElement).className).toContain('session-tab');
});
it('shows the live session count in the sidebar header', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
app.sessions = new Map([
['a', {}],
['b', {}],
['c', {}],
]);
app.applySessionListLayout();
expect(win.document.getElementById('sessionSidebarCount')?.textContent).toBe('3');
});
it('forces tall rows and no wrapping in the sidebar, and leaves the strip rules alone', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar', tabTwoRows: false } });
app.applySessionListLayout();
const tabs = tabsEl(win);
expect(tabs.classList.contains('tabs-show-folder')).toBe(true);
expect(tabs.classList.contains('tabs-two-rows')).toBe(false);
expect(tabs.classList.contains('tabs-auto-wrap')).toBe(false);
expect(app._tallTabsEnabled).toBe(true);
});
});
describe('session list layout wiring', () => {
it('accepts sessionListLayout in the strict settings schema', () => {
// SettingsUpdateSchema is .strict() and this key is NOT in the PUT strip-list,
// so without the schema entry the server 400s the ENTIRE settings PUT and every
// unrelated setting silently stops persisting.
expect(SCHEMAS).toContain("sessionListLayout: z.enum(['header', 'sidebar']).optional()");
});
it('plumbs the setting through populate, collect, defaults and the display-key set', () => {
expect(INDEX_HTML).toContain('id="appSettingsSessionListLayout"');
expect(SETTINGS_UI).toContain("document.getElementById('appSettingsSessionListLayout').value =");
expect(SETTINGS_UI).toContain("sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,");
expect(SETTINGS_UI).toContain("sessionListLayout: 'header',");
expect(SETTINGS_UI).toContain("'sessionListLayout'");
// Saving must re-apply the LAYOUT (which calls applyTabWrapSettings itself);
// calling only applyTabWrapSettings would leave a layout change unapplied.
expect(SETTINGS_UI).toContain('this.applySessionListLayout();');
});
it('keeps the header host, the aside and the toggle out of solo windows', () => {
expect(STYLES_CSS).toContain('body.solo-mode .session-tabs-host,');
expect(STYLES_CSS).toContain('body.solo-mode .session-sidebar,');
expect(STYLES_CSS).toContain('body.solo-mode .btn-sidebar-toggle,');
});
it('puts the sidebar rules after the skin nesting block and adds no colour to .session-tab', () => {
// Match the RULE (column 0 + opening brace), not the prose about it in the
// sidebar block's own header comment.
const skinRule = [...STYLES_CSS.matchAll(/^html:not\(\[data-skin="og"\]\) \{/gm)].pop();
expect(skinRule).toBeDefined();
const sidebarBlock = STYLES_CSS.indexOf('=== Collapsible session sidebar');
expect(sidebarBlock).toBeGreaterThan(skinRule!.index!);
});
it('makes the handheld sidebar an off-canvas overlay from the END of mobile.css', () => {
// Placement is load-bearing: the compact `.session-tabs, .session-tabs.tabs-two-rows`
// blocks earlier in the file pin max-height 36px/52px. Moving this block up
// collapses the list into a sliver that looks like an empty list.
const overlay = MOBILE_CSS.indexOf('SESSION SIDEBAR — off-canvas drawer');
const compactStrip = [...MOBILE_CSS.matchAll(/^\s*\.session-tabs\.tabs-two-rows \{/gm)].pop();
expect(compactStrip).toBeDefined();
expect(overlay).toBeGreaterThan(compactStrip!.index!);
expect(MOBILE_CSS).toContain('html[data-session-list="sidebar"] .session-sidebar.open');
expect(MOBILE_CSS).toContain('transform: translateX(-100%)');
});
it('translates the new sidebar copy for every language the translator supports', () => {
for (const key of [
'Collapse session sidebar',
'Expand session sidebar',
'Filter sessions',
'Session List Layout',
'Header tab strip',
'Left sidebar',
]) {
expect(I18N).toContain(`'${key}'`);
}
});
it('pre-paints the layout before first paint and never in a solo window', () => {
expect(INDEX_HTML).toContain('document.documentElement.dataset.sessionList');
expect(INDEX_HTML).toContain('/^\\/session\\//.test(location.pathname)');
});
it('pre-paints the collapse default off the SAME 1024px breakpoint as the JS', () => {
// The handheld storage-key heuristic `m` is a different predicate; using it
// here made boot contradict the pre-paint value between 768 and 1023px, so
// the drawer animated itself open over the terminal on every load.
expect(INDEX_HTML).toContain("dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')");
});
it('keeps the sidebar toggle chord out of the PTY', () => {
// preventDefault() in the document CAPTURE handler does not stop xterm, so
// without this gate Alt+B would also write ESC b (readline backward-word)
// into the live session on every toggle.
expect(TERMINAL_UI).toContain('this.shouldToggleSessionSidebarFromShortcut?.(ev)');
expect(APP).toContain('shouldToggleSessionSidebarFromShortcut(e) {');
});
it('keeps the session drawer out of the prev/next swipe zone', () => {
// The <aside> is a child of .main, which is where SwipeHandler binds, so a
// swipe across the open drawer would otherwise fire nextSession().
expect(MOBILE_HANDLERS).toContain("e.target?.closest?.('.session-sidebar')");
});
});