Merge pull request #580: feat(tabs): search sessions by name on the vertical rail

This commit is contained in:
Codeman maintainer
2026-10-10 03:54:39 +02:00
11 changed files with 1069 additions and 13 deletions
+1 -1
View File
@@ -337,7 +337,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
+1
View File
@@ -22,6 +22,7 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
'test/tab-rail-search.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
+1 -1
View File
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. Desktop and tablet only. |
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
+125 -11
View File
@@ -717,6 +717,7 @@ class CodemanApp {
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
this._lastTabGroupStructureKey = null;
this._tabRailSearch = ''; // rail search box text: in memory only, never persisted
this.cases = [];
this.currentRun = null;
this.totalTokens = 0;
@@ -5755,27 +5756,132 @@ class CodemanApp {
*/
applySidebarFilter(query) {
this._sidebarFilter = (query ?? '').trim().toLowerCase();
this._applyTabListFilter();
}
/**
* The ONE row filter behind both search boxes: the sidebar's filter box and
* the vertical rail's search box (only one of the two hosts the list at a
* time). Classes only, over whatever the last render drew, so grouping, order,
* Alt+N badges and the server layout never move; the matching itself is the
* pure CodemanTabSearch (constants.js).
*
* - Sidebar: name (aria-label) + working directory (title), as it always has.
* - Rail: the NAME only, a web tab's title included (it is a row in the same
* list, and hiding every web tab would make a dashboard unfindable).
*
* A session row with a tab alert (red action or yellow idle, whatever
* tabAlerts holds, the set a collapsed group header surfaces) stays visible
* even when it does not match: a prompt waiting on you is never hidden by a
* view filter. Alerts come and go through renderSessionTabs(), and both
* render paths end here, so nothing else re-runs this for them.
*
* A group or case box left with nothing showing hides with its header, its
* count shows the rows left showing (a kept row included), and the grouped
* tree's roving stop and posinset follow the visible items. A collapsed group's rows are not in the DOM at all,
* which is why the rail search also expands the projection (_projectTabGroups).
*/
_applyTabListFilter() {
const container = this.$('sessionTabs');
if (!container) return;
const reachable =
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const needle = reachable ? this._sidebarFilter : '';
const rail = this._tabOrientation() === 'vertical';
const sidebarReachable =
!rail && this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const query = rail ? this._tabRailSearch : sidebarReachable ? this._sidebarFilter : '';
const rows = [...container.querySelectorAll('.session-tab')].map((tab) => ({
key: tab,
text: rail
? this._tabRowSearchName(tab)
: `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`,
section: tab.closest('.tab-layout-group, .tab-cluster'),
// Web tabs carry no alerts; only a session row can be kept.
keep: !tab.dataset.webviewId && !!tab.dataset.id && !!this.tabAlerts?.get(tab.dataset.id),
}));
const result = window.CodemanTabSearch?.filter(rows, query);
if (!result) return;
// State headings count the whole group, so they step aside while a filter
// is narrowing the rows under them (styles.css, .tabs-filtering).
container.classList.toggle('tabs-filtering', !!needle);
for (const tab of container.querySelectorAll('.session-tab')) {
if (!needle) {
tab.classList.remove('tab-filtered-out');
continue;
container.classList.toggle('tabs-filtering', result.active);
for (const row of rows) row.key.classList.toggle('tab-filtered-out', result.hidden.has(row.key));
for (const section of container.querySelectorAll('.tab-layout-group, .tab-cluster')) {
const shown = result.counts.get(section) ?? 0;
section.classList.toggle('tab-filtered-out', result.active && shown === 0);
const count = section.querySelector('.tab-layout-group-count, .tab-cluster-count');
if (!count) continue;
if (count.dataset.total === undefined) count.dataset.total = count.textContent;
const text = result.active ? String(shown) : count.dataset.total;
if (count.textContent !== text) count.textContent = text;
}
const empty = document.getElementById('tabRailSearchEmpty');
if (empty) empty.hidden = !(rail && result.active && result.matchCount === 0);
if (container.getAttribute('role') === 'tree') {
const items = this._applyTabTreePositions(container);
const stop = container.querySelector('[role="treeitem"][tabindex="0"]');
if (items.length && !items.includes(stop)) {
this._setTabTreeStop(container, items.find((item) => item.getAttribute('aria-selected') === 'true') || items[0]);
}
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
}
// The count shows visible rows, so it moves with every filter change —
// including keystrokes in the filter box, which call this directly.
this.updateSidebarCount();
}
/** What the rail search matches on a row: a session's name, a web tab's title. */
_tabRowSearchName(tab) {
if (tab.dataset.webviewId) return this.webviews?.get(tab.dataset.webviewId)?.name || '';
return tab.querySelector('.tab-name')?.dataset.fullName || '';
}
/** True while the vertical rail's search box is narrowing the list. */
_tabRailSearchActive() {
return this._tabOrientation() === 'vertical' && !!window.CodemanTabSearch?.needle(this._tabRailSearch);
}
/**
* The rail search box's input handler. In-memory only: never persisted, never
* sent anywhere. Starting or ending a search re-renders once when it changes
* what a collapsed group hides (the projection ignores collapse while
* searching); every other keystroke only re-applies the row classes.
*/
setTabRailSearch(value) {
this._tabRailSearch = typeof value === 'string' ? value : '';
const clear = document.getElementById('tabRailSearchClear');
if (clear) clear.hidden = this._tabRailSearch.length === 0;
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
else this._applyTabListFilter();
}
/** Clear button (and Escape): empty the box, restore the list, keep focus in the box. */
clearTabRailSearch() {
const input = document.getElementById('tabRailSearch');
if (input) input.value = '';
this.setTabRailSearch('');
input?.focus();
}
handleTabRailSearchKeydown(event) {
if (event.key !== 'Escape' || !this._tabRailSearch) return;
// Consumed here: the global Escape handler would otherwise close whatever
// else is open on the same keypress.
event.preventDefault();
event.stopPropagation();
this.clearTabRailSearch();
}
/**
* Forget the search without rendering: the list is leaving the rail
* (applyTabOrientation), and the render that follows draws it unfiltered.
*/
_resetTabRailSearch() {
this._tabRailSearch = '';
const input = document.getElementById('tabRailSearch');
if (input) input.value = '';
const clear = document.getElementById('tabRailSearchClear');
if (clear) clear.hidden = true;
const empty = document.getElementById('tabRailSearchEmpty');
if (empty) empty.hidden = true;
}
// ═══════════════════════════════════════════════════════════════
// Rich sidebar rows (sessionListLayout === 'sidebar-rich')
// ═══════════════════════════════════════════════════════════════
@@ -6984,6 +7090,8 @@ class CodemanApp {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
const items = [];
for (const section of container.querySelectorAll('.tab-layout-group')) {
// A group the search emptied is hidden whole, header included.
if (section.classList.contains('tab-filtered-out')) continue;
const header = section.querySelector(':scope > [role="treeitem"]');
if (header) items.push(header);
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
@@ -7387,7 +7495,10 @@ class CodemanApp {
// session the layout has not placed yet lands where the flat strip has it.
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
collapsedGroupIds: [...this.collapsedTabGroupIds],
// A rail search shows matches inside collapsed groups too, so it projects
// every group open. The stored per-device collapse state is untouched and
// applies again as soon as the search is cleared.
collapsedGroupIds: this._tabRailSearchActive() ? [] : [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
});
@@ -7408,6 +7519,9 @@ class CodemanApp {
* A storage failure leaves every group expanded rather than half-remembered.
*/
toggleTabGroupCollapsed(groupId, forceCollapsed) {
// Every group is drawn open while the rail search runs; a toggle then would
// change what the user sees only after the search is cleared.
if (this._tabRailSearchActive()) return false;
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
const next = new Set(this.collapsedTabGroupIds);
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
+52
View File
@@ -1020,6 +1020,54 @@ function tabClusterNameSplit(name, label) {
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
}
/**
* Session-list search: the vertical rail's search box and the sidebar's filter
* box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
* @param {unknown} query
* @returns {string} the needle, '' when there is nothing to search for
*/
function tabSearchNeedle(query) {
return typeof query === 'string' ? query.trim().toLocaleLowerCase() : '';
}
/**
* Which rows a search hides. Pure: the caller reads the rows off the list it
* rendered and applies the result as classes, so the list itself (grouping,
* order, Alt+N badges) is never rebuilt or reordered by a search.
*
* A row flagged `keep: true` is never hidden, matching or not (the caller keeps
* a tab with an alert on screen: a prompt waiting on you is never hidden by a
* view filter). It counts toward its section, so its group stays on screen with
* it, but not toward `matchCount`.
*
* @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
* in list order; `section` is the row's group or case box, null/undefined for none.
* @param {unknown} query
* @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
* `counts` is the rows left showing per section (kept rows included), every
* section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
* number of rows whose TEXT matched, so it can be 0 above a lone kept row.
*/
function filterTabSearchRows(rows, query) {
const needle = tabSearchNeedle(query);
const hidden = new Set();
const counts = new Map();
let matchCount = 0;
for (const row of Array.isArray(rows) ? rows : []) {
const hasSection = row.section !== null && row.section !== undefined;
if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
const text = typeof row.text === 'string' ? row.text.toLocaleLowerCase() : '';
const matches = !needle || text.includes(needle);
if (!matches && row.keep !== true) {
hidden.add(row.key);
continue;
}
if (matches) matchCount++;
if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
}
return { active: needle.length > 0, hidden, counts, matchCount };
}
// Terminal font stack — the single source for every xterm surface (the main
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -1415,6 +1463,10 @@ if (typeof window !== 'undefined') {
groupFor: tabTriageGroupFor,
layout: computeTabTriageLayout,
};
window.CodemanTabSearch = {
needle: tabSearchNeedle,
filter: filterTabSearchRows,
};
window.CodemanInputLimit = {
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
+2
View File
@@ -62,6 +62,8 @@
'Collapse session sidebar': '收起会话侧边栏',
'Expand session sidebar': '展开会话侧边栏',
'Filter sessions': '筛选会话',
'Search sessions': '搜索会话',
'No sessions match': '没有匹配的会话',
'Admin Panel': '管理面板',
'Open admin panel': '打开管理面板',
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
+13
View File
@@ -394,6 +394,19 @@
<!-- Main Terminal Area -->
<main class="main">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation">
<!-- Session-name search (app.js setTabRailSearch): a view filter over the
rows below, in memory only. Shown with the rail, never elsewhere. -->
<div class="session-sidebar-filter tab-rail-search">
<input type="search" id="tabRailSearch" class="session-sidebar-filter-input"
placeholder="Search sessions" aria-label="Search sessions"
autocomplete="off" spellcheck="false"
oninput="app.setTabRailSearch(this.value)"
onkeydown="app.handleTabRailSearchKeydown(event)">
<button type="button" id="tabRailSearchClear" class="tab-rail-search-clear"
aria-label="Clear search" title="Clear search"
onclick="app.clearTabRailSearch()" hidden>&times;</button>
</div>
<div id="tabRailSearchEmpty" class="tab-rail-search-empty" role="status" hidden>No sessions match</div>
<div
id="tabRailResizeHandle"
class="tab-rail-resize-handle"
+4
View File
@@ -3997,6 +3997,10 @@ Object.assign(CodemanApp.prototype, {
})
: 'horizontal';
// The search box lives in the rail: a search left applied after the list
// moves out would hide tabs with no box to clear it from.
if (orientation !== 'vertical' && this._tabRailSearch) this._resetTabRailSearch?.();
const root = document.documentElement;
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
root.setAttribute('data-tab-orientation', orientation);
+68
View File
@@ -18819,6 +18819,74 @@ html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
display: none !important;
}
/* The vertical rail's session search (app.js _applyTabListFilter) uses the same
class, and also hides a group or case box the search emptied. Rail-scoped for
the same reason as the sidebar rule above: the header strip has no box. */
html[data-tab-orientation='vertical'] .tab-rail .session-tab.tab-filtered-out,
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group.tab-filtered-out,
html[data-tab-orientation='vertical'] .tab-rail .tab-cluster.tab-filtered-out {
display: none !important;
}
/* The box reuses .session-sidebar-filter / -input; this only adds the clear
button over its right edge and the "No sessions match" line. */
.tab-rail-search {
position: relative;
/* Clear of the 16px resize handle on the rail's right edge. */
padding-right: calc(0.25rem + 16px);
}
.tab-rail-search .session-sidebar-filter-input {
padding-right: 1.6rem;
}
/* The native WebKit clear glyph would sit under ours. */
.tab-rail-search .session-sidebar-filter-input::-webkit-search-cancel-button {
appearance: none;
}
.tab-rail-search-clear {
position: absolute;
top: 50%;
right: calc(0.45rem + 16px);
width: 1.2rem;
height: 1.2rem;
padding: 0;
transform: translateY(-50%);
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-dim);
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
}
.tab-rail-search-clear:hover,
.tab-rail-search-clear:focus-visible {
background: var(--control-bg-hover);
color: var(--text);
}
.tab-rail-search-clear[hidden],
.tab-rail-search-empty[hidden] {
display: none;
}
.tab-rail-search-empty {
flex-shrink: 0;
padding: 0.5rem 0.75rem;
color: var(--text-dim);
font-size: 0.75rem;
text-align: center;
}
/* Every group is drawn open while searching and its header will not toggle,
so the chevron dims to say so. */
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-filtering .tab-layout-group-chevron {
opacity: 0.35;
}
/* --- Rich rows (sessionListLayout 'sidebar-rich' + tabRailDetail 'rich') --- */
/* The detailed variant of the SAME sidebar: identical column, identical
re-parented #sessionTabs, identical filter and Alt+B toggle. The only
+227
View File
@@ -0,0 +1,227 @@
/**
* @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.
*
* Port: none (page.route on a fake origin, no server).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
/** The shipped rail markup, so the inline handlers under test are the real ones. */
const RAIL_HTML = (() => {
const dom = new JSDOM(read('index.html'));
const html = dom.window.document.getElementById('tabRail')!.outerHTML;
dom.window.close();
return html;
})();
const LAYOUT = {
version: 1,
updatedAt: '2026-10-01T00:00:00.000Z',
groups: [
{ id: 'eng', name: 'Engineering', refs: [{ kind: 'session', id: 'alpha' }] },
{
id: 'plan',
name: 'Planning',
refs: [
{ kind: 'session', id: 'roadmap' },
{ kind: 'session', id: 'review' },
],
},
],
ungrouped: [
{ kind: 'webview', id: 'web' },
{ kind: 'session', id: 'notes' },
],
};
describe('vertical rail session search in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
await page.route('http://codeman.test/', (route) =>
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
);
await page.goto('http://codeman.test/');
await page.setContent(`<!doctype html>
<html data-tab-orientation="vertical" data-tab-rail-sort="manual">
<head><style>${read('styles.css')}</style></head>
<body>
<main class="main" style="width:100%;height:760px">${RAIL_HTML}</main>
</body>
</html>`);
await page.addScriptTag({
content:
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('tab-layout-browser.js') +
'\n' +
read('app.js') +
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
});
await page.addScriptTag({ content: read('webview-tabs.js') });
await page.evaluate(() => {
const w = window as any;
const app = Object.create(w.CodemanApp.prototype);
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
['alpha', { id: 'alpha', name: 'Alpha API', status: 'idle' }],
['roadmap', { id: 'roadmap', name: 'Roadmap', status: 'idle' }],
['review', { id: 'review', name: 'API Review', status: 'idle' }],
['notes', { id: 'notes', name: 'Notes', status: 'idle' }],
]);
app.sessionOrder = ['alpha', 'roadmap', 'review', 'notes'];
app.webviews = new Map([['web', { id: 'web', name: 'Dashboard', url: 'https://example.test', icon: 'D' }]]);
app.webviewOrder = ['web'];
app._hiddenTabGroupByRef = new Map();
app._inlineRenameActive = false;
app._sidebarFilter = '';
app._tabRailSearch = '';
app.tabAlerts = new Map();
app.terminalLoadStates = new Map();
app.minimizedSubagents = new Map();
app.hasTabDetachOverride = () => false;
app.renderSubagentTabBadge = () => '';
app.cancelHideSubagentDropdown = () => undefined;
app.updateTabOverflowMode = () => undefined;
app.updateConnectionLines = () => undefined;
app._applyTabEntrances = () => undefined;
app._scrollActiveTabIntoView = () => undefined;
app.isSessionSidebarActive = () => false;
app._startSidebarRichClock = () => undefined;
app._stopSidebarRichClock = () => undefined;
app.selectSession = (id: string) => {
w.__activation = `session:${id}`;
};
app.openWebview = (id: string) => {
w.__activation = `webview:${id}`;
};
w.__setApp(app);
w.__app = app;
});
});
afterAll(async () => browser.close());
beforeEach(async () => {
await page.evaluate((layout) => {
const w = window as any;
const app = w.__app;
document.getElementById('sessionTabs')?.remove();
document
.getElementById('tabRail')!
.insertAdjacentHTML(
'beforeend',
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
);
localStorage.setItem('codeman:tab-groups-collapsed', '["plan"]');
app._tabKeydownHandler = null;
app._tabTreeFocusinHandler = null;
app.tabLayout = layout;
app.collapsedTabGroupIds = new Set(['plan']);
app.activeSessionId = 'alpha';
app.activeWebviewId = null;
app._lastTabGroupStructureKey = null;
app._resetTabRailSearch();
app._fullRenderSessionTabs();
w.__activation = null;
}, LAYOUT);
});
/** Rows actually painted (display != none), in DOM order. */
const paintedRows = () =>
page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')]
.filter((tab) => tab.getClientRects().length > 0)
.map((tab) => tab.dataset.webviewId || tab.dataset.id)
);
const paintedGroups = () =>
page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>('#sessionTabs .tab-layout-group')]
.filter((section) => section.getClientRects().length > 0)
.map((section) => section.dataset.tabGroupId)
);
it('reveals a collapsed match, selects it, and restores the collapse when cleared', async () => {
const search = page.getByRole('searchbox', { name: 'Search sessions' });
expect(await search.isVisible()).toBe(true);
expect(await paintedRows()).toEqual(['alpha', 'web', 'notes']);
expect(await page.locator('[data-tab-group-header="plan"]').getAttribute('aria-expanded')).toBe('false');
await search.fill('aPi');
expect(await paintedRows()).toEqual(['alpha', 'review']);
expect(await paintedGroups()).toEqual(['eng', 'plan']);
expect(await page.locator('[data-tab-group-header="plan"]').getAttribute('aria-expanded')).toBe('true');
await page.locator('#sessionTabs [data-id="review"]').click();
expect(await page.evaluate(() => (window as any).__activation)).toBe('session:review');
const clear = page.getByRole('button', { name: 'Clear search' });
expect(await clear.isVisible()).toBe(true);
await clear.click();
expect(await search.inputValue()).toBe('');
expect(await search.evaluate((el) => el === document.activeElement)).toBe(true);
expect(await clear.isHidden()).toBe(true);
expect(await page.locator('[data-tab-group-header="plan"]').getAttribute('aria-expanded')).toBe('false');
expect(await paintedRows()).toEqual(['alpha', 'web', 'notes']);
expect(await page.evaluate(() => localStorage.getItem('codeman:tab-groups-collapsed'))).toBe('["plan"]');
});
it('says so when nothing matches, and Escape in the box clears it', async () => {
const search = page.getByRole('searchbox', { name: 'Search sessions' });
await search.fill('zzz');
expect(await paintedRows()).toEqual([]);
expect(await paintedGroups()).toEqual([]);
expect(await page.getByRole('status').filter({ hasText: 'No sessions match' }).isVisible()).toBe(true);
await search.press('Escape');
expect(await search.inputValue()).toBe('');
expect(await page.locator('#tabRailSearchEmpty').isHidden()).toBe(true);
expect(await paintedRows()).toEqual(['alpha', 'web', 'notes']);
});
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.
expect(await paintedRows()).toEqual(['alpha', 'roadmap', 'review', 'web']);
await page.locator('#sessionTabs [data-id="alpha"]').focus();
const walk: string[] = [];
for (let i = 0; i < 6; i++) {
await page.keyboard.press('ArrowDown');
walk.push(
await page.evaluate(() => {
const el = document.activeElement as HTMLElement;
return el.dataset.webviewId || el.dataset.id || el.dataset.tabGroupHeader || el.id;
})
);
}
expect(walk).toEqual(['plan', 'roadmap', 'review', 'web', 'eng', 'alpha']);
});
it('is hidden with the rail, on the horizontal strip', async () => {
await page.evaluate(() => document.documentElement.setAttribute('data-tab-orientation', 'horizontal'));
expect(await page.locator('#tabRailSearch').isVisible()).toBe(false);
await page.evaluate(() => document.documentElement.setAttribute('data-tab-orientation', 'vertical'));
});
});
+575
View File
@@ -0,0 +1,575 @@
/**
* @fileoverview Session-name search on the vertical tab rail.
*
* What is pinned, and why:
* - The pure matcher (`CodemanTabSearch` in constants.js): trimmed,
* case-insensitive substring, a whitespace-only query is no query, and every
* section that ends up empty is reported so the caller can hide it.
* - It is a VIEW filter over the one #sessionTabs list, the same row classes the
* sidebar filter box uses: grouping, order, Alt+N badges and the layout on the
* server never change, and nothing is written anywhere.
* - A match inside a COLLAPSED group is shown: the projection ignores collapse
* while a search is active, without touching the per-device collapse state,
* and the header cannot be collapsed until the search is cleared.
* - Groups with no match hide, an empty result says so, and it works on the
* flat rail (no groups) too.
* - Only the grouped rail is a tree: hidden rows and the headers of hidden
* groups leave the roving walk and the posinset/setsize count.
* - The rail matches the NAME (a web tab's title); the sidebar keeps matching
* name + working directory.
* - The input is labelled, sits at the top of #tabRail, and every new string
* reads in zh-CN.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
type SearchResult = {
active: boolean;
hidden: Set<unknown>;
counts: Map<unknown, number>;
matchCount: number;
};
type TabSearch = {
needle: (query: unknown) => string;
filter: (
rows: Array<{ key: unknown; text: string; section?: unknown; keep?: boolean }>,
query: unknown
) => SearchResult;
};
function loadSearch(): TabSearch {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanTabSearch: TabSearch }).CodemanTabSearch;
}
describe('CodemanTabSearch (pure)', () => {
const search = loadSearch();
it('normalizes a query: trimmed, lower-cased, anything else is empty', () => {
expect(search.needle(' ApI ')).toBe('api');
expect(search.needle(' ')).toBe('');
expect(search.needle(undefined)).toBe('');
expect(search.needle(42)).toBe('');
});
it('keeps case-insensitive substring matches and counts them per section', () => {
const rows = [
{ key: 'a', text: 'Alpha API', section: 'g1' },
{ key: 'b', text: 'Roadmap', section: 'g1' },
{ key: 'c', text: 'api review', section: 'g2' },
{ key: 'd', text: 'Notes', section: 'g3' },
{ key: 'e', text: 'Flat API row', section: null },
];
const result = search.filter(rows, 'aPi');
expect(result.active).toBe(true);
expect([...result.hidden]).toEqual(['b', 'd']);
expect(result.matchCount).toBe(3);
// Every section is reported, empty ones as 0, so the caller can hide them.
expect([...result.counts]).toEqual([
['g1', 1],
['g2', 1],
['g3', 0],
]);
});
it('is a no-op for an empty or whitespace query', () => {
const rows = [
{ key: 'a', text: 'One', section: 'g1' },
{ key: 'b', text: 'Two', section: null },
];
for (const query of ['', ' ', undefined]) {
const result = search.filter(rows, query);
expect(result.active).toBe(false);
expect(result.hidden.size).toBe(0);
expect(result.matchCount).toBe(2);
}
});
it('never hides a kept row, counts it toward its section, and leaves it out of matchCount', () => {
const rows = [
{ key: 'a', text: 'Alpha', section: 'g1', keep: true },
{ key: 'b', text: 'Beta', section: 'g1' },
{ key: 'c', text: 'Gamma', section: 'g2' },
{ key: 'd', text: 'Delta', section: null, keep: true },
];
const result = search.filter(rows, 'zzz');
expect(result.active).toBe(true);
expect([...result.hidden]).toEqual(['b', 'c']);
// Zero TEXT matches: "No sessions match" still shows above the kept rows.
expect(result.matchCount).toBe(0);
// The kept row keeps its group on screen; the group without one empties.
expect([...result.counts]).toEqual([
['g1', 1],
['g2', 0],
]);
// A kept row that also matches is one match, not two.
expect(search.filter(rows, 'alpha').matchCount).toBe(1);
// Only a literal true keeps a row.
expect(search.filter([{ key: 'x', text: 'X', keep: 'yes' as never }], 'zzz').hidden.has('x')).toBe(true);
});
it('reports no matches without throwing on odd input', () => {
const result = search.filter([{ key: 'a', text: '', section: 'g' }], 'x');
expect(result.matchCount).toBe(0);
expect(result.counts.get('g')).toBe(0);
expect(search.filter(null as never, 'x').matchCount).toBe(0);
});
});
// ─── The rail, driven through the shipping CodemanApp ────────────────────────
let CodemanApp: { prototype: Record<string, any> };
let window: any;
let document: Document;
let localStorage: Storage;
beforeAll(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
window = dom.window;
document = window.document;
localStorage = window.localStorage;
window.setInterval = () => 0;
window.requestAnimationFrame = () => 0;
window.CSS = { escape: (value: string) => value };
window.eval(
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('tab-layout-browser.js') +
'\n' +
read('app.js') +
'\n' +
read('webview-tabs.js') +
'\n;window.__RailSearchCodemanApp = CodemanApp;'
);
CodemanApp = window.__RailSearchCodemanApp;
});
const LAYOUT = {
version: 8,
updatedAt: '2026-10-01T00:00:00.000Z',
groups: [
{
id: 'eng',
name: 'Engineering',
refs: [
{ kind: 'session', id: 'alpha' },
{ kind: 'webview', id: 'web' },
],
},
{
id: 'plan',
name: 'Planning',
refs: [
{ kind: 'session', id: 'roadmap' },
{ kind: 'session', id: 'review' },
],
},
],
ungrouped: [{ kind: 'session', id: 'notes' }],
};
function makeApp(options: { tabLayout?: unknown } = {}) {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
document.documentElement.dataset.tabRailSort = 'manual';
document.body.innerHTML = `
<aside class="tab-rail" id="tabRail">
<div class="tab-rail-search">
<input id="tabRailSearch" type="search" aria-label="Search sessions">
<button id="tabRailSearchClear" type="button" aria-label="Clear search" hidden></button>
</div>
<div id="tabRailSearchEmpty" role="status" hidden>No sessions match</div>
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>
</aside>`;
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
['alpha', { id: 'alpha', name: 'Alpha API', status: 'idle', workingDir: '/srv/alpha' }],
['roadmap', { id: 'roadmap', name: 'Roadmap', status: 'idle', workingDir: '/srv/api-plans' }],
['review', { id: 'review', name: 'API Review', status: 'idle' }],
['notes', { id: 'notes', name: 'Notes', status: 'busy' }],
]);
app.sessionOrder = ['alpha', 'roadmap', 'review', 'notes'];
app.webviews = new Map([['web', { id: 'web', name: 'Grafana Dashboard', url: 'https://example.test/api' }]]);
app.webviewOrder = ['web'];
app.activeSessionId = 'alpha';
app.activeWebviewId = null;
app.tabLayout = 'tabLayout' in options ? options.tabLayout : LAYOUT;
app.collapsedTabGroupIds = new Set();
app._hiddenTabGroupByRef = new Map();
app._lastTabGroupStructureKey = null;
app._tabCollapseStorageFailed = false;
app._inlineRenameActive = false;
app._sidebarFilter = '';
app._tabRailSearch = '';
app.tabAlerts = new Map();
app.pendingHooks = new Map();
app._debounceTimers = {};
app.terminalLoadStates = new Map();
app.minimizedSubagents = new Map();
app.hasTabDetachOverride = () => false;
app.renderSubagentTabBadge = () => '';
app.cancelHideSubagentDropdown = () => {};
app.updateTabOverflowMode = () => {};
app.updateConnectionLines = () => {};
app._applyTabEntrances = () => {};
app._scrollActiveTabIntoView = () => {};
app._refreshMobileOverviewIfVisible = () => {};
app._refreshHomeSessionsIfVisible = () => {};
app.isSessionSidebarActive = () => false;
app._startSidebarRichClock = () => {};
app._stopSidebarRichClock = () => {};
return app;
}
/** Rows the user can see: rendered and not filtered out. */
const visibleRows = () =>
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab:not(.tab-filtered-out)')].map(
(tab) => tab.dataset.webviewId || tab.dataset.id
);
const visibleGroups = () =>
[...document.querySelectorAll<HTMLElement>('#sessionTabs .tab-layout-group:not(.tab-filtered-out)')].map(
(section) => section.dataset.tabGroupId
);
const badgeOf = (id: string) => document.querySelector(`[data-id="${id}"] .tab-number`)?.textContent ?? null;
const input = () => document.getElementById('tabRailSearch') as HTMLInputElement;
const clearButton = () => document.getElementById('tabRailSearchClear') as HTMLButtonElement;
const emptyNote = () => document.getElementById('tabRailSearchEmpty') as HTMLElement;
beforeEach(() => {
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
document.body.innerHTML = '';
localStorage.removeItem('codeman:tab-groups-collapsed');
});
afterEach(() => vi.restoreAllMocks());
describe('grouped rail search', () => {
it('shows a match inside a collapsed group without touching the stored collapse state', () => {
const app = makeApp();
localStorage.setItem('codeman:tab-groups-collapsed', '["plan"]');
app.collapsedTabGroupIds = new Set(['plan']);
app._fullRenderSessionTabs();
expect(visibleRows()).toEqual(['alpha', 'web', 'notes']);
const badgesBefore = ['alpha', 'notes'].map(badgeOf);
app.setTabRailSearch('aPi');
expect(visibleRows()).toEqual(['alpha', 'review']);
expect(visibleGroups()).toEqual(['eng', 'plan']);
const plan = document.querySelector('[data-tab-group-header="plan"]')!;
expect(plan.getAttribute('aria-expanded')).toBe('true');
// Counts follow the search, so a header never claims rows it is not showing.
expect(plan.querySelector('.tab-layout-group-count')?.textContent).toBe('1');
expect([...app.collapsedTabGroupIds]).toEqual(['plan']);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["plan"]');
// Alt+N badges name the session order, never the filtered position.
expect(['alpha', 'notes'].map(badgeOf)).toEqual(badgesBefore);
expect(badgeOf('review')).toBe('3');
});
it('will not collapse or expand a group while searching, and restores collapse on clear', () => {
const app = makeApp();
localStorage.setItem('codeman:tab-groups-collapsed', '["plan"]');
app.collapsedTabGroupIds = new Set(['plan']);
app._fullRenderSessionTabs();
app.setTabRailSearch('review');
expect(app.toggleTabGroupCollapsed('plan')).toBe(false);
expect(app.toggleTabGroupCollapsed('eng', true)).toBe(false);
expect([...app.collapsedTabGroupIds]).toEqual(['plan']);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["plan"]');
expect(visibleRows()).toEqual(['review']);
app.clearTabRailSearch();
expect(input().value).toBe('');
expect(visibleRows()).toEqual(['alpha', 'web', 'notes']);
expect(document.querySelector('[data-tab-group-header="plan"]')!.getAttribute('aria-expanded')).toBe('false');
expect(document.querySelector('[data-tab-group-header="plan"] .tab-layout-group-count')?.textContent).toBe('2');
});
it('filters web tabs by their title, and never by URL or working directory', () => {
const app = makeApp();
app._fullRenderSessionTabs();
app.setTabRailSearch('grafana');
expect(visibleRows()).toEqual(['web']);
// 'api' is in the web tab's URL and in roadmap's working directory: neither counts.
app.setTabRailSearch('api');
expect(visibleRows()).toEqual(['alpha', 'review']);
app.setTabRailSearch('/srv');
expect(visibleRows()).toEqual([]);
});
it('hides every group and says so when nothing matches', () => {
const app = makeApp();
app._fullRenderSessionTabs();
expect(emptyNote().hidden).toBe(true);
app.setTabRailSearch('zzz');
expect(visibleRows()).toEqual([]);
expect(visibleGroups()).toEqual([]);
expect(emptyNote().hidden).toBe(false);
expect(clearButton().hidden).toBe(false);
app.setTabRailSearch(' ');
expect(visibleRows()).toEqual(['alpha', 'web', 'roadmap', 'review', 'notes']);
expect(emptyNote().hidden).toBe(true);
});
it('keeps hidden rows and the headers of hidden groups out of the tree walk', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const tabs = document.getElementById('sessionTabs')!;
expect(tabs.getAttribute('role')).toBe('tree');
app.setTabRailSearch('notes');
const items = app._tabTreeItems(tabs) as HTMLElement[];
expect(items.map((item) => item.dataset.id || item.dataset.tabGroupHeader || 'ungrouped')).toEqual(['notes']);
// The one roving stop moved onto something the user can see.
const stops = [...tabs.querySelectorAll('[role="treeitem"][tabindex="0"]')] as HTMLElement[];
expect(stops.map((el) => el.dataset.id)).toEqual(['notes']);
expect(stops[0].getAttribute('aria-posinset')).toBe('1');
expect(stops[0].getAttribute('aria-setsize')).toBe('1');
});
it('survives the re-render an SSE tick triggers, and re-matches a renamed session', () => {
const app = makeApp();
app._fullRenderSessionTabs();
app.setTabRailSearch('api');
app._fullRenderSessionTabs();
expect(visibleRows()).toEqual(['alpha', 'review']);
app.sessions.get('notes').name = 'API notes';
app._renderSessionTabsImmediate();
expect(visibleRows()).toEqual(['alpha', 'review', 'notes']);
});
it('is a view filter only: no request, no order change, no layout change', () => {
const app = makeApp();
const fetchSpy = vi.fn();
window.fetch = fetchSpy;
app._fullRenderSessionTabs();
const layoutBefore = JSON.stringify(app.tabLayout);
app.setTabRailSearch('api');
app.clearTabRailSearch();
expect(fetchSpy).not.toHaveBeenCalled();
expect(app.sessionOrder).toEqual(['alpha', 'roadmap', 'review', 'notes']);
expect(JSON.stringify(app.tabLayout)).toBe(layoutBefore);
});
});
describe('alerted rows stay visible during a search', () => {
/** What the app does on a hook event: pendingHooks, then updateTabAlertFromHooks (debounced render). */
async function setHook(app: Record<string, any>, id: string, hook: string | null) {
if (hook) app.pendingHooks.set(id, new Set([hook]));
else app.pendingHooks.delete(id);
app.updateTabAlertFromHooks(id);
await new Promise((resolve) => window.setTimeout(resolve, 150));
}
it('keeps a non-matching alerted row painted and its group on screen', async () => {
const app = makeApp();
app._fullRenderSessionTabs();
await setHook(app, 'alpha', 'permission_prompt');
expect(app.tabAlerts.get('alpha')).toBe('action');
app.setTabRailSearch('review');
expect(visibleRows()).toEqual(['alpha', 'review']);
expect(visibleGroups()).toEqual(['eng', 'plan']);
// The header counts the rows it is showing, the kept one included.
expect(document.querySelector('[data-tab-group-header="eng"] .tab-layout-group-count')?.textContent).toBe('1');
expect(emptyNote().hidden).toBe(true);
});
it('keeps the yellow idle alert too, and still says nothing matched', async () => {
const app = makeApp();
app._fullRenderSessionTabs();
await setHook(app, 'roadmap', 'idle_prompt');
expect(app.tabAlerts.get('roadmap')).toBe('idle');
app.setTabRailSearch('zzz');
expect(visibleRows()).toEqual(['roadmap']);
expect(visibleGroups()).toEqual(['plan']);
// matchCount is text matches only: the note explains why the row is there.
expect(emptyNote().hidden).toBe(false);
});
it('hides the row again once the alert clears and the rail re-renders', async () => {
const app = makeApp();
app._fullRenderSessionTabs();
app.setTabRailSearch('review');
expect(visibleRows()).toEqual(['review']);
await setHook(app, 'alpha', 'elicitation_dialog');
expect(visibleRows()).toEqual(['alpha', 'review']);
await setHook(app, 'alpha', null);
expect(app.tabAlerts.has('alpha')).toBe(false);
expect(visibleRows()).toEqual(['review']);
expect(visibleGroups()).toEqual(['plan']);
});
it('applies to the sidebar filter box as well', async () => {
const app = makeApp({ tabLayout: null });
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
app.isSessionSidebarActive = () => true;
app._fullRenderSessionTabs();
await setHook(app, 'notes', 'permission_prompt');
app.applySidebarFilter('/srv/api');
expect(visibleRows()).toEqual(['roadmap', 'notes']);
});
});
describe('flat rail search (no groups)', () => {
it('filters the flat list, keeps it a tablist, and reports an empty result', () => {
const app = makeApp({ tabLayout: null });
app._fullRenderSessionTabs();
const tabs = document.getElementById('sessionTabs')!;
expect(tabs.getAttribute('role')).toBe('tablist');
app.setTabRailSearch('ROAD');
expect(visibleRows()).toEqual(['roadmap']);
expect(tabs.getAttribute('role')).toBe('tablist');
expect(badgeOf('roadmap')).toBe('2');
app.setTabRailSearch('nothing here');
expect(visibleRows()).toEqual([]);
expect(emptyNote().hidden).toBe(false);
});
it('does not apply on the horizontal strip, where there is no box to clear it', () => {
const app = makeApp({ tabLayout: null });
app._fullRenderSessionTabs();
app.setTabRailSearch('road');
expect(visibleRows()).toEqual(['roadmap']);
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
app._fullRenderSessionTabs();
expect(visibleRows()).toEqual(['alpha', 'roadmap', 'review', 'notes', 'web']);
});
});
describe('the search box', () => {
it('clears on Escape and on the clear button, handing focus back to the box', () => {
const app = makeApp();
app._fullRenderSessionTabs();
input().value = 'review';
app.setTabRailSearch(input().value);
expect(clearButton().hidden).toBe(false);
const escape = new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
app.handleTabRailSearchKeydown(escape);
expect(escape.defaultPrevented).toBe(true);
expect(input().value).toBe('');
expect(clearButton().hidden).toBe(true);
expect(visibleRows()).toEqual(['alpha', 'web', 'roadmap', 'review', 'notes']);
expect(document.activeElement).toBe(input());
// Escape on an empty box is left alone for the global handler.
const second = new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
app.handleTabRailSearchKeydown(second);
expect(second.defaultPrevented).toBe(false);
app.setTabRailSearch('notes');
input().blur();
app.clearTabRailSearch();
expect(document.activeElement).toBe(input());
expect(visibleRows()).toHaveLength(5);
});
it('is cleared when the list leaves the vertical orientation (settings-ui.js)', () => {
const settings = read('settings-ui.js');
const start = settings.indexOf(' applyTabOrientation(options = {}) {');
const body = settings.slice(start, settings.indexOf('\n },\n', start));
expect(body).toMatch(/orientation !== 'vertical'[\s\S]*_resetTabRailSearch\?\.\(\)/);
// ...and before the render the orientation change triggers.
expect(body.indexOf('_resetTabRailSearch')).toBeLessThan(body.indexOf('_fullRenderSessionTabs'));
});
it('resets state and box without rendering', () => {
const app = makeApp();
app._fullRenderSessionTabs();
app.setTabRailSearch('notes');
const full = vi.spyOn(app, '_fullRenderSessionTabs');
app._resetTabRailSearch();
expect(app._tabRailSearch).toBe('');
expect(input().value).toBe('');
expect(clearButton().hidden).toBe(true);
expect(full).not.toHaveBeenCalled();
});
});
describe('the sidebar filter keeps its own matching', () => {
it('still matches the working directory in the sidebar, where the rail does not', () => {
const app = makeApp({ tabLayout: null });
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
app.isSessionSidebarActive = () => true;
app._fullRenderSessionTabs();
app.applySidebarFilter('/srv/api');
expect(visibleRows()).toEqual(['roadmap']);
expect(emptyNote().hidden).toBe(true);
});
});
// ─── Markup + zh-CN ───────────────────────────────────────────────────────────
describe('markup and zh-CN', () => {
const INDEX = read('index.html');
const doms: JSDOM[] = [];
afterAll(() => doms.forEach((dom) => dom.window.close()));
it('puts a labelled search box at the top of #tabRail', () => {
const dom = new JSDOM(INDEX);
doms.push(dom);
const rail = dom.window.document.getElementById('tabRail')!;
const box = rail.querySelector<HTMLInputElement>('#tabRailSearch')!;
expect(box).not.toBeNull();
expect(box.getAttribute('aria-label')).toBe('Search sessions');
expect(box.getAttribute('placeholder')).toBe('Search sessions');
expect(box.getAttribute('oninput')).toBe('app.setTabRailSearch(this.value)');
expect(box.getAttribute('onkeydown')).toBe('app.handleTabRailSearchKeydown(event)');
expect(rail.firstElementChild?.contains(box)).toBe(true);
const clear = rail.querySelector('#tabRailSearchClear')!;
expect(clear.getAttribute('aria-label')).toBe('Clear search');
expect(clear.hasAttribute('hidden')).toBe(true);
const empty = rail.querySelector('#tabRailSearchEmpty')!;
expect(empty.getAttribute('role')).toBe('status');
expect(empty.textContent!.trim()).toBe('No sessions match');
});
it('reads in Chinese', () => {
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
doms.push(dom);
vm.runInContext(read('i18n.js'), dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: { start(): void; configure(o: object): void } }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const doc = dom.window.document;
const box = doc.getElementById('tabRailSearch')!;
expect(box.getAttribute('placeholder')).toBe('搜索会话');
expect(box.getAttribute('aria-label')).toBe('搜索会话');
expect(doc.getElementById('tabRailSearchClear')!.getAttribute('aria-label')).toBe('清除搜索');
expect(doc.getElementById('tabRailSearchEmpty')!.textContent!.trim()).toBe('没有匹配的会话');
});
});