feat(tabs): give the vertical rail the home screen's per-session detail

The vertical tab rail (tabOrientation 'vertical') listed names and nothing
else, while the rich sidebar and both home screens already answered the
question a docked column exists to answer: which of these sessions wants me
next, and how long has it been like that. The rail is a docked column too, so
it now draws the same row.

- New per-device setting tabRailDetail ('rich' | 'simple', default rich),
  App Settings -> Appearance -> Tabs, in SettingsUpdateSchema + displayKeys and
  stamped as data-tab-rail-detail by the pre-paint script, so a detailed rail
  does not flash through simple rows on every load.
- ONE gate for both vertical surfaces: isRichTabRows() =
  isSessionSidebarRich() || isTabRailRich(). The row model, the markup and the
  20s in-place clock are the existing rich-sidebar ones, classified by
  _mobileOverviewState/_mobileOverviewSince, so the rail, the sidebar, the
  desktop home rail and the phone overview cannot disagree about what
  "working" means or which stamp measures it.
- Detail rides on its OWN attribute, exactly as the sidebar's does, so every
  existing [data-tab-orientation='vertical'] rule keeps matching both variants
  untouched. A flip of detail ALONE still forces a full render (the stamps line
  is emitted by the row template, not toggled by CSS) and re-runs
  applyTabWrapSettings(), which owns the folder line and is now rail-aware.
- CSS: every rich paint rule gains a rail twin as a COMMA-GROUPED selector,
  never :is() - an :is() list takes its most specific argument, which would
  lift the sidebar arm from (0,3,1) to the rail's (0,5,1) and let these rules
  outrank things they never used to.
- Width is why there are thresholds. At 256px the stamps line ellipsizes
  mid-word, the same reason the rich sidebar is 300px, so a rail that has never
  been sized defaults to 320 (RICH_DEFAULT_WIDTH, the existing Wide preset,
  which also keeps the settings select on a named choice). A width the user has
  chosen is never overridden: below 288px the created stamp is dropped rather
  than truncated (tab-rail-tight, CSS only) and below 240px the rows go back to
  simple (tab-rail-compact, which re-renders).
- The rich clock is armed and disarmed by applyTabOrientation() as well as
  applySessionListLayout(); a leaked interval would rewrite stamps in a list
  that no longer has any.

Also fixes a data-loss bug in the inline tab rename that predates the rail and
reproduces in every layout, header strip included: Escape set the input to ''
and blurred it, and the blur handler commits - so cancelling a rename PUT an
empty name, and the tab fell back to its folder label (measured against a live
server: ["rail-alpha","","rail-gamma"]). Escape now calls cancelRename(), which
invalidates the edit so the blur that follows the input's removal is a no-op.

Tests: rail-detail gate, the three ways it turns back off (simple, compact,
horizontal), sidebar-wins, render-on-detail-flip and the plumbing/CSS guards in
test/session-list-layout.test.ts; the rename cancel in test/inline-rename.test.ts
(browser suite), pinned by running it against the old code first. Verified live
against a real server on an isolated instance: detailed/simple/compact/header/
sidebar variants, click-select, the ... menu, inline rename, Alt+N, the in-place
stamp tick and a full settings-picker round-trip including reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-24 04:45:02 +02:00
parent 9cfd8e8989
commit b330f1d9e8
12 changed files with 372 additions and 40 deletions
+39
View File
@@ -125,6 +125,45 @@ describe('Inline rename input', () => {
expect(renameStillActive).toBe(true);
});
it('Escape cancels the rename instead of committing an empty name', async () => {
await resetState();
expect(await startRename('esc-cancel', 'rail-beta')).toBe(true);
// Escape used to clear the field and blur, and the blur handler commits —
// so cancelling a rename PUT an empty name, and the tab fell back to its
// folder label (measured against a live server, in the header strip as well
// as both vertical layouts). The observable here is the REQUEST: this
// harness's server has no such session, so a failed PUT would leave the
// local map looking innocent.
const result = await page.evaluate(async () => {
const app = (window as unknown as { app: { _activeRename: unknown } }).app;
const calls: string[] = [];
const origFetch = window.fetch;
window.fetch = (async (input: RequestInfo | URL) => {
calls.push(String(input));
return new Response('{"success":true}', { status: 200 });
}) as typeof window.fetch;
const inputEl = document.querySelector('input.tab-rename-input') as HTMLInputElement;
inputEl.value = 'typed-but-abandoned';
inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// The blur that follows the input's removal must not resurrect the commit.
inputEl.dispatchEvent(new Event('blur'));
await new Promise((r) => setTimeout(r, 50));
window.fetch = origFetch;
return {
renamePuts: calls.filter((url) => url.includes('/api/sessions/esc-cancel/name')),
renameActive: !!app._activeRename,
inputStillInDom: document.body.contains(inputEl),
};
});
expect(result.renamePuts).toEqual([]);
expect(result.renameActive).toBe(false);
expect(result.inputStillInDom).toBe(false);
});
it('CJK guard: regular Enter (no IME) DOES commit', async () => {
await resetState();
expect(await startRename('regular-enter', 'OldName')).toBe(true);
+111 -1
View File
@@ -54,6 +54,8 @@ interface LayoutApp {
_fullRenderSessionTabs(): void;
updateConnectionLines(): void;
isSessionSidebarRich(): boolean;
isTabRailRich(): boolean;
isRichTabRows(): boolean;
_sidebarRichRow(id: string, session: Record<string, unknown>): RichRow | null;
_sidebarRichMetaHTML(row: RichRow | null): string;
_updateSidebarRichRow(tab: Element, id: string, session: Record<string, unknown>): void;
@@ -795,7 +797,7 @@ describe('rich session sidebar', () => {
// .tab-info is already a flex column, so the line needs no row-level
// wrapping — and the collapsed 44px rail hides .tab-info wholesale, which is
// what keeps the stamps out of it for free.
expect(APP).toContain('const richRows = this.isSessionSidebarRich();');
expect(APP).toContain('const richRows = this.isRichTabRows();');
expect(APP).toContain('const richMeta = this._sidebarRichMetaHTML(richRow);');
expect(APP).toContain('${richMeta}\n </span>');
});
@@ -819,3 +821,111 @@ describe('rich session sidebar', () => {
expect(MOBILE_CSS).toContain('html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {');
});
});
describe('detailed rows in the vertical tab rail', () => {
/**
* The rail is the SECOND surface that draws rich rows. Everything about the
* row itself (model, markup, clock) is shared with the sidebar and covered
* above; what is new here is only the gate — which attribute turns it on,
* and the three ways it must turn back off.
*/
const railBoot = (stored: Record<string, unknown>) => {
const booted = boot({ stored: { sessionListLayout: 'header', ...stored } });
booted.app.applySessionListLayout();
booted.app.applyTabOrientation();
return booted;
};
it('defaults the rail to detailed rows, since a docked column is not a tab strip', () => {
const { win, app } = railBoot({ tabOrientation: 'vertical' });
expect(win.document.documentElement.dataset.tabOrientation).toBe('vertical');
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
expect(app.isTabRailRich()).toBe(true);
expect(app.isRichTabRows()).toBe(true);
// The stamps go stale with no event behind them, so the clock has to run.
expect(app._sidebarRichClock).toBeTruthy();
});
it("honors the 'simple' opt-out", () => {
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'simple' });
expect(win.document.documentElement.dataset.tabRailDetail).toBe('simple');
expect(app.isTabRailRich()).toBe(false);
expect(app.isRichTabRows()).toBe(false);
// Falsy rather than null: _stopSidebarRichClock() returns early when there
// is no interval to clear, which is the state a rail that never armed one is in.
expect(app._sidebarRichClock).toBeFalsy();
});
it('drops back to simple rows once the rail is dragged into compact width', () => {
// Below 240px the rail already hides the row actions; three lines of stamps
// in a ~208px column ellipsize into noise. _setTabRailWidth() re-renders
// whenever this class flips, so the gate is re-read at the right moment.
const { win, app } = railBoot({ tabOrientation: 'vertical' });
expect(app.isTabRailRich()).toBe(true);
win.document.documentElement.classList.add('tab-rail-compact');
expect(app.isTabRailRich()).toBe(false);
expect(app.isRichTabRows()).toBe(false);
});
it('never draws stamps in the horizontal header strip', () => {
// tabRailDetail stays 'rich' in storage while the orientation is horizontal:
// the gate has to read BOTH, or the header strip inherits a meta line that
// has nowhere to go.
const { win, app } = railBoot({ tabOrientation: 'horizontal', tabRailDetail: 'rich' });
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
expect(app.isTabRailRich()).toBe(false);
expect(app.isRichTabRows()).toBe(false);
});
it('leaves the simple sidebar simple even with the rail set to detailed', () => {
// The sidebar owns the tabs whenever it is active, which forces the
// orientation back to horizontal — so a rail preference must not leak a
// meta line into a list the user asked to keep compact.
const { app } = railBoot({ sessionListLayout: 'sidebar', tabOrientation: 'vertical', tabRailDetail: 'rich' });
expect(app.isSessionSidebarActive()).toBe(true);
expect(app.isSessionSidebarRich()).toBe(false);
expect(app.isTabRailRich()).toBe(false);
expect(app.isRichTabRows()).toBe(false);
});
it('re-renders when only the DETAIL changes, orientation untouched', () => {
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'simple' });
(app._fullRenderSessionTabs as unknown as { mockClear(): void }).mockClear();
win.localStorage.setItem(
'codeman-app-settings',
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'vertical', tabRailDetail: 'rich' })
);
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applyTabOrientation();
// The stamps line is emitted by the row template, not toggled by CSS: a
// missed render here means the setting repaints nothing until the next tick.
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
expect(app._fullRenderSessionTabs).toHaveBeenCalled();
expect(app._sidebarRichClock).toBeTruthy();
});
it('plumbs the rail detail through the settings UI, the schema and the pre-paint script', () => {
expect(INDEX_HTML).toContain('id="appSettingsTabRailDetail"');
expect(INDEX_HTML).toContain('<option value="rich">Detailed</option>');
// Pre-paint stamps it with the rest of the layout keys, or a detailed rail
// paints as a simple one for the first frame and then jumps a row taller.
expect(INDEX_HTML).toContain("dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich'");
expect(SETTINGS_UI).toContain("document.getElementById('appSettingsTabRailDetail').value");
const displayKeys = SETTINGS_UI.slice(SETTINGS_UI.indexOf('const displayKeys = new Set(['));
expect(displayKeys.slice(0, 1800)).toContain("'tabRailDetail'");
expect(SCHEMAS).toMatch(/tabRailDetail:\s*z\.enum\(\['simple',\s*'rich'\]\)\.optional\(\)/);
});
it('gives every rich paint rule a rail twin without raising the sidebar arm', () => {
// Comma-grouped, never :is() — an :is() list takes its most specific
// argument, which would lift the sidebar selectors from (0,3,1) to the
// rail's (0,5,1) and let them outrank rules they never used to.
const rail = "html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail";
for (const suffix of ['.tab-meta', '.tab-meta-key', '.tab-pill', '.tab-pill--working']) {
expect(STYLES_CSS).toContain(`${rail} ${suffix}`);
}
expect(STYLES_CSS).not.toContain(':is(html[data-sidebar-detail="rich"]');
});
});