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
+46 -12
View File
@@ -3826,6 +3826,38 @@ class CodemanApp {
return root.dataset.sessionList === 'sidebar' && root.dataset.sidebarDetail === 'rich';
}
/**
* True when the VERTICAL TAB RAIL (tabOrientation 'vertical') is showing the
* detailed rows: the same "created 3d ago · working 12m" line and status pill
* the rich sidebar and both home screens carry.
*
* A docked column is not a tab strip — that was the argument for the rich
* sidebar, and the rail is a docked column too, so it defaults to rich and
* `tabRailDetail: 'simple'` is the opt-out.
*
* The compact carve-out is not cosmetic: below 240px the rail already drops
* the row actions to a hover affordance, and three lines of stamps in a
* ~208px column ellipsize into noise. `_setTabRailWidth()` re-renders the
* tabs whenever that class flips, so this gate is re-read at the right moment.
*/
isTabRailRich() {
const root = document.documentElement;
return (
root.getAttribute('data-tab-orientation') === 'vertical' &&
root.dataset.tabRailDetail === 'rich' &&
!root.classList.contains('tab-rail-compact')
);
}
/**
* The one gate the render paths ask: does THIS list draw detailed rows?
* Either vertical surface can, and neither can be on at once (the sidebar
* owns the tabs whenever it is active, which forces the rail off).
*/
isRichTabRows() {
return this.isSessionSidebarRich() || this.isTabRailRich();
}
/**
* True where the sidebar is a MODAL off-canvas drawer over the terminal
* instead of a docked column.
@@ -3999,7 +4031,7 @@ class CodemanApp {
this.showHomeSessions?.();
}
// Only the rich rows carry stamps that go stale with no event behind them.
if (this.isSessionSidebarRich()) this._startSidebarRichClock();
if (this.isRichTabRows()) this._startSidebarRichClock();
else this._stopSidebarRichClock();
}
@@ -4116,13 +4148,14 @@ class CodemanApp {
}
/**
* The per-row model for a rich sidebar row: which state the session is in,
* when it was first created, and how long it has been in that state.
* The per-row model for a rich row (detailed sidebar or vertical tab rail):
* which state the session is in, when it was first created, and how long it
* has been in that state.
*
* Classification is `_mobileOverviewState()` and the state duration is
* `_mobileOverviewSince()` (both mobile-overview.js), NOT re-derived here —
* the sidebar, the desktop home rail and the phone overview must never
* disagree about what "working" means or about which stamp measures it.
* the sidebar, the rail, the desktop home rail and the phone overview must
* never disagree about what "working" means or about which stamp measures it.
*
* Guarded like every other cross-file consumer in this app: a stale cached
* mobile-overview.js must degrade to a row with no meta line, not throw and
@@ -4214,7 +4247,7 @@ class CodemanApp {
_startSidebarRichClock() {
if (this._sidebarRichClock) return;
this._sidebarRichClock = setInterval(() => {
if (!this.isSessionSidebarRich()) {
if (!this.isRichTabRows()) {
this._stopSidebarRichClock();
return;
}
@@ -4431,7 +4464,7 @@ class CodemanApp {
if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches
// the DOM and the loop below runs for every session on every SSE tick.
const richRows = this.isSessionSidebarRich();
const richRows = this.isRichTabRows();
// Incremental update - only modify changed properties
for (const [id, session] of this.sessions) {
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
@@ -4740,9 +4773,9 @@ class CodemanApp {
// into view replaces it.
const parts = [];
const tabOrder = this.sessionOrder;
// Read once, not per session: isSessionSidebarRich() touches the DOM and
// Read once, not per session: isRichTabRows() touches the DOM and
// this loop runs for every tab on every full rebuild.
const richRows = this.isSessionSidebarRich();
const richRows = this.isRichTabRows();
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
@@ -4786,9 +4819,10 @@ class CodemanApp {
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
: (session.workingDir || '');
// Rich sidebar rows only: the home screen's created/state stamps and a
// status pill. richRow is null in every other layout, and both helpers
// below collapse to '' — the header strip's markup is unchanged.
// Rich rows only (the detailed sidebar OR the vertical tab rail): the home
// screen's created/state stamps and a status pill. richRow is null in every
// other layout, and both helpers below collapse to '' — the header strip's
// markup is unchanged.
const richRow = richRows ? this._sidebarRichRow(id, session) : null;
const richMeta = this._sidebarRichMetaHTML(richRow);
const richClass = richRow ? ` tab-state-${richRow.state}` : '';