mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-13 02:39:42 +02:00
938 lines
39 KiB
TypeScript
938 lines
39 KiB
TypeScript
/**
|
||
* @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. Lower-casing never follows the browser locale
|
||
* (a Turkish locale lowers "API" to "apı").
|
||
* - A session row with a tab alert is never hidden, and keeps its group or
|
||
* case box on screen (the owner's call on #580).
|
||
* - Escape in a box that holds text clears the search and closes nothing else:
|
||
* the global key handler runs first (capture phase) and must route it.
|
||
* - A change in what shows redraws the connector lines, which are anchored to
|
||
* row positions; an unchanged re-apply does not.
|
||
* - The sidebar hides a case box its filter emptied, like the rail.
|
||
* - A floating window whose parent row the search hid draws no connector,
|
||
* spawns where it would without a tab and tears down without a genie: a
|
||
* hidden row measures as an all-zero rect, which is truthy, so it used to
|
||
* anchor them at the viewport's top-left corner.
|
||
* - 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);
|
||
});
|
||
|
||
it('lower-cases without the browser locale, so a Turkish locale still finds "API"', () => {
|
||
// A context of its own, whose locale-aware lower-casing behaves like the
|
||
// Turkish locale (I -> dotless i). The prototypes are this context's alone.
|
||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||
vm.runInContext(
|
||
"String.prototype.toLocaleLowerCase = function () { return String(this).replace(/I/g, '\\u0131').toLowerCase(); };",
|
||
context
|
||
);
|
||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||
const turkish = (context.window as { CodemanTabSearch: TabSearch }).CodemanTabSearch;
|
||
expect(vm.runInContext("'API'.toLocaleLowerCase()", context)).toBe('apı');
|
||
|
||
expect(turkish.needle('API')).toBe('api');
|
||
const result = turkish.filter([{ key: 'r', text: 'API Review', section: null }], 'api');
|
||
expect(result.hidden.size).toBe(0);
|
||
expect(result.matchCount).toBe(1);
|
||
});
|
||
});
|
||
|
||
// ─── 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' +
|
||
read('subagent-windows.js') +
|
||
'\n' +
|
||
read('ultracode-windows.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);
|
||
});
|
||
|
||
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<HTMLElement>(`.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', () => {
|
||
/**
|
||
* The real global key handler from setupEventListeners(), on document in the
|
||
* capture phase, with the close methods it calls (settings-ui.js and
|
||
* panels-ui.js are not loaded here) as recorders. The box in this harness
|
||
* has no inline onkeydown, so only the global handler can clear it.
|
||
*/
|
||
function installGlobalKeys(app: Record<string, any>) {
|
||
const closed: string[] = [];
|
||
app.setupColorPicker = () => {};
|
||
app.closeAllPanels = () => closed.push('panels');
|
||
app.closeHelp = () => closed.push('help');
|
||
app.closeSessionManager = () => closed.push('session-manager');
|
||
app.closeCommandPalette = () => {};
|
||
app.closeShortcutOverlay = () => {};
|
||
const add = vi.spyOn(document, 'addEventListener');
|
||
app.setupEventListeners();
|
||
const listener = add.mock.calls.find(([type]) => type === 'keydown')![1] as EventListener;
|
||
add.mockRestore();
|
||
return { closed, remove: () => document.removeEventListener('keydown', listener, true) };
|
||
}
|
||
const escape = () => new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
||
|
||
it('clears a search and closes nothing else, though the global handler runs first', () => {
|
||
const app = makeApp();
|
||
app._fullRenderSessionTabs();
|
||
const keys = installGlobalKeys(app);
|
||
try {
|
||
input().value = 'review';
|
||
app.setTabRailSearch(input().value);
|
||
input().focus();
|
||
|
||
const event = escape();
|
||
input().dispatchEvent(event);
|
||
expect(event.defaultPrevented).toBe(true);
|
||
expect(input().value).toBe('');
|
||
expect(app._tabRailSearch).toBe('');
|
||
expect(visibleRows()).toEqual(['alpha', 'web', 'roadmap', 'review', 'notes']);
|
||
expect(keys.closed).toEqual([]);
|
||
|
||
// The box is empty now: the next Escape is the global handler's again.
|
||
input().dispatchEvent(escape());
|
||
expect(keys.closed).toEqual(['panels', 'help', 'session-manager']);
|
||
} finally {
|
||
keys.remove();
|
||
}
|
||
});
|
||
|
||
it('only claims an Escape that lands in the box', () => {
|
||
const app = makeApp();
|
||
app._fullRenderSessionTabs();
|
||
const keys = installGlobalKeys(app);
|
||
try {
|
||
app.setTabRailSearch('review');
|
||
document.body.dispatchEvent(escape());
|
||
expect(keys.closed).toEqual(['panels', 'help', 'session-manager']);
|
||
expect(app._tabRailSearch).toBe('review');
|
||
expect(visibleRows()).toEqual(['review']);
|
||
} finally {
|
||
keys.remove();
|
||
}
|
||
});
|
||
|
||
it('leaves an Escape that cancels an IME composition to the IME', () => {
|
||
const app = makeApp();
|
||
app._fullRenderSessionTabs();
|
||
app.setTabRailSearch('review');
|
||
const composing = new window.KeyboardEvent('keydown', { key: 'Escape', isComposing: true, cancelable: true });
|
||
app.handleTabRailSearchKeydown(composing);
|
||
expect(composing.defaultPrevented).toBe(false);
|
||
expect(app._tabRailSearch).toBe('review');
|
||
});
|
||
});
|
||
|
||
describe('connector lines follow the rows a search moves', () => {
|
||
it('redraws on a rail keystroke that hides or reveals rows, and only then', () => {
|
||
const app = makeApp({ tabLayout: null });
|
||
app._fullRenderSessionTabs();
|
||
const redraw = vi.fn();
|
||
app.updateConnectionLines = redraw;
|
||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||
|
||
app.setTabRailSearch('road');
|
||
// The keystroke path: no render, which would have redrawn them itself.
|
||
expect(full).not.toHaveBeenCalled();
|
||
expect(redraw).toHaveBeenCalledTimes(1);
|
||
|
||
// Same rows showing: nothing moved, nothing to redraw.
|
||
app.setTabRailSearch('roa');
|
||
expect(redraw).toHaveBeenCalledTimes(1);
|
||
|
||
app.setTabRailSearch('');
|
||
expect(redraw).toHaveBeenCalledTimes(2);
|
||
// The re-apply every render tail runs costs nothing when nothing changed.
|
||
app._applyTabListFilter();
|
||
expect(redraw).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('redraws on a grouped rail when a group empties, and when the empty note shows', () => {
|
||
const app = makeApp();
|
||
app._fullRenderSessionTabs();
|
||
const redraw = vi.fn();
|
||
app.updateConnectionLines = redraw;
|
||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||
|
||
app.setTabRailSearch('notes');
|
||
expect(full).not.toHaveBeenCalled();
|
||
// Only the Ungrouped section (no group id) is left showing.
|
||
expect(visibleGroups()).toEqual(['']);
|
||
expect(redraw).toHaveBeenCalledTimes(1);
|
||
|
||
// Nothing matches: the rows were already gone, but the note pushes the list down.
|
||
app.setTabRailSearch('notesz');
|
||
expect(emptyNote().hidden).toBe(false);
|
||
expect(redraw).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('redraws on a sidebar filter keystroke too', () => {
|
||
const app = makeApp({ tabLayout: null });
|
||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||
app.isSessionSidebarActive = () => true;
|
||
app._fullRenderSessionTabs();
|
||
const redraw = vi.fn();
|
||
app.updateConnectionLines = redraw;
|
||
|
||
app.applySidebarFilter('/srv/api');
|
||
expect(visibleRows()).toEqual(['roadmap']);
|
||
expect(redraw).toHaveBeenCalledTimes(1);
|
||
app.applySidebarFilter('/srv/api');
|
||
expect(redraw).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe('floating windows whose parent row the search hid', () => {
|
||
type Box = { left: number; top: number; width: number; height: number };
|
||
const rect = ({ left, top, width, height }: Box) => ({
|
||
left,
|
||
top,
|
||
width,
|
||
height,
|
||
right: left + width,
|
||
bottom: top + height,
|
||
x: left,
|
||
y: top,
|
||
});
|
||
|
||
/**
|
||
* jsdom has no layout: every rect is zero and getClientRects() is always
|
||
* empty. Lay the page out by hand the way a browser would: painted rows
|
||
* stack 40px apart down the rail, a window takes the box in its
|
||
* data-box, and anything a filter hid (`tab-filtered-out` on it or an
|
||
* ancestor) has no client rects and an all-zero, still truthy, rect.
|
||
*/
|
||
function layOut() {
|
||
const hidden = (el: Element) => !el.isConnected || !!el.closest('.tab-filtered-out');
|
||
const box = (el: Element) => {
|
||
if (hidden(el)) return rect({ left: 0, top: 0, width: 0, height: 0 });
|
||
if (el.classList.contains('session-tab')) {
|
||
const rows = [...document.querySelectorAll('#sessionTabs .session-tab')].filter((row) => !hidden(row));
|
||
return rect({ left: 0, top: 100 + rows.indexOf(el) * 40, width: 240, height: 32 });
|
||
}
|
||
const [left, top, width, height] = ((el as HTMLElement).dataset?.box ?? '0,0,0,0').split(',').map(Number);
|
||
return rect({ left, top, width, height });
|
||
};
|
||
vi.spyOn(window.Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||
return box(this);
|
||
});
|
||
vi.spyOn(window.Element.prototype, 'getClientRects').mockImplementation(function (this: Element) {
|
||
return (hidden(this) ? [] : [box(this)]) as never;
|
||
});
|
||
}
|
||
|
||
/** A subagent window, an ultracode run window and an ultracode agent window, all from "API Review". */
|
||
function openWindows(app: Record<string, any>) {
|
||
document.body.insertAdjacentHTML(
|
||
'beforeend',
|
||
'<svg id="connectionLines"></svg>' +
|
||
'<div id="subWin" data-box="600,300,320,200"></div>' +
|
||
'<div id="runWin" data-box="600,560,320,160"></div>' +
|
||
'<div id="agentWin" data-box="960,300,280,200"></div>'
|
||
);
|
||
app.subagentWindows = new Map([
|
||
['ag1', { element: document.getElementById('subWin'), minimized: false, hidden: false }],
|
||
]);
|
||
app.subagentParentMap = new Map([['ag1', 'review']]);
|
||
app.planSubagents = new Map();
|
||
app.ultracodeWindows = new Map([
|
||
['run1', { element: document.getElementById('runWin'), parentSessionId: 'review' }],
|
||
]);
|
||
// An agent window whose run has no window of its own anchors to the run's tab.
|
||
app.ultracodeAgentWindows = new Map([['ua1', { element: document.getElementById('agentWin'), runId: 'run2' }]]);
|
||
app.workflowRuns = new Map([['run2', { runId: 'run2' }]]);
|
||
app._resolveUltracodeParentSession = () => 'review';
|
||
}
|
||
|
||
/** What the shared SVG pass drew: which window each line serves, and where it starts. */
|
||
const lines = () =>
|
||
[...document.querySelectorAll('#connectionLines path')].map((path) => ({
|
||
win: path.getAttribute('data-run-id') || path.getAttribute('data-agent-id'),
|
||
from: (path.getAttribute('d') || '').split(' C ')[0],
|
||
}));
|
||
|
||
it('draws no connector from a hidden parent row, and draws it again once the search is cleared', () => {
|
||
const app = makeApp({ tabLayout: null });
|
||
app._fullRenderSessionTabs();
|
||
layOut();
|
||
openWindows(app);
|
||
// review is the third row: top 180, so its right edge's midpoint is (240, 196).
|
||
const fromReview = [
|
||
{ win: 'ag1', from: 'M 240 196' },
|
||
{ win: 'run1', from: 'M 240 196' },
|
||
{ win: 'ua1', from: 'M 240 196' },
|
||
];
|
||
|
||
app._updateConnectionLinesImmediate();
|
||
expect(lines()).toEqual(fromReview);
|
||
|
||
app.setTabRailSearch('notes');
|
||
// The trap: the hidden row still answers with a rect, all zero but truthy.
|
||
const row = document.querySelector('#sessionTabs [data-id="review"]')!;
|
||
expect(row.getBoundingClientRect()).toMatchObject({ left: 0, top: 0, width: 0 });
|
||
app._updateConnectionLinesImmediate();
|
||
expect(lines()).toEqual([]);
|
||
|
||
app.clearTabRailSearch();
|
||
app._updateConnectionLinesImmediate();
|
||
expect(lines()).toEqual(fromReview);
|
||
});
|
||
|
||
it('spawns an ultracode window from a painted parent row, and cascades instead when the row is hidden', () => {
|
||
const app = makeApp({ tabLayout: null });
|
||
app._fullRenderSessionTabs();
|
||
layOut();
|
||
app.ultracodeWindows = new Map();
|
||
app.ultracodeWindowZIndex = 0;
|
||
app._resolveUltracodeParentSession = () => 'review';
|
||
app.makeWindowDraggable = () => ({});
|
||
app.renderUltracodeWindowContent = () => {};
|
||
app._fetchWorkflowRunDetail = () => {};
|
||
const spawn = (runId: string) => {
|
||
app.createUltracodeWindow({ runId, workflowName: runId });
|
||
const win = document.getElementById(`ultracode-window-${runId}`) as HTMLElement;
|
||
return { left: win.style.left, top: win.style.top };
|
||
};
|
||
|
||
app.setTabRailSearch('notes');
|
||
// The cascade's first slot, not the corner a zero rect would give.
|
||
expect(spawn('hidden-parent')).toEqual({ left: '24px', top: '96px' });
|
||
|
||
app.clearTabRailSearch();
|
||
// To the right of the row (rail layout): right edge 240 + 14, at the row's top.
|
||
expect(spawn('painted-parent')).toEqual({ left: '254px', top: '180px' });
|
||
});
|
||
|
||
it('tears an ultracode window down at once rather than flying it to a hidden row', () => {
|
||
const app = makeApp({ tabLayout: null });
|
||
app._fullRenderSessionTabs();
|
||
layOut();
|
||
const win = document.createElement('div');
|
||
win.dataset.box = '600,300,320,200';
|
||
document.body.appendChild(win);
|
||
|
||
app.setTabRailSearch('notes');
|
||
const gone = vi.fn();
|
||
app._animateUltracodeWindowToTab(win, 'review', gone);
|
||
expect(gone).toHaveBeenCalledTimes(1);
|
||
expect(win.style.transition).toBe('');
|
||
|
||
app.clearTabRailSearch();
|
||
const flown = vi.fn();
|
||
app._animateUltracodeWindowToTab(win, 'review', flown);
|
||
expect(flown).not.toHaveBeenCalled();
|
||
expect(win.style.transition).toContain('transform');
|
||
win.dispatchEvent(new window.Event('transitionend'));
|
||
expect(flown).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
// ─── 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('没有匹配的会话');
|
||
});
|
||
});
|