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
+20
View File
@@ -0,0 +1,20 @@
---
"aicodeman": patch
---
Vertical tab rail: detailed rows, and a rename cancel that no longer wipes the name.
The vertical rail (Tab Orientation → Vertical) now draws the same per-session
line the home screen and the rich sidebar draw — when the session was created,
how long it has been in the state it is in, the folder it runs in, and a status
pill — instead of just the name. New per-device setting **Vertical Rail Rows**
(`tabRailDetail`, App Settings → Appearance → Tabs) with `Detailed` as the
default and `Simple (name only)` as the opt-out. A rail that has never been
sized now opens at 320px (the existing Wide preset) so the line fits; a narrower
rail sheds the created stamp below 288px and falls back to simple rows below
240px.
Also fixes a data-loss bug in the inline tab rename that predates the rail:
pressing Escape cleared the input and blurred it, and the blur handler commits —
so cancelling a rename stored an EMPTY session name and the tab fell back to its
folder label. Escape now cancels without a request, in every layout.
+1 -1
View File
File diff suppressed because one or more lines are too long
+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}` : '';
+4
View File
@@ -164,6 +164,9 @@ function resolveTabOrientation(input) {
const TAB_RAIL_MIN_WIDTH = 208;
const TAB_RAIL_DEFAULT_WIDTH = 256;
/** Detailed rows carry a third line, and it ellipsizes at 256px — see the
rich sidebar's own 300px column. 320px is the existing Wide preset. */
const TAB_RAIL_RICH_DEFAULT_WIDTH = 320;
const TAB_RAIL_MAX_WIDTH = 360;
function resolveTabRailWidth(input = {}) {
@@ -686,6 +689,7 @@ if (typeof window !== 'undefined') {
};
window.CodemanTabRail = {
DEFAULT_WIDTH: TAB_RAIL_DEFAULT_WIDTH,
RICH_DEFAULT_WIDTH: TAB_RAIL_RICH_DEFAULT_WIDTH,
MIN_WIDTH: TAB_RAIL_MIN_WIDTH,
MAX_WIDTH: TAB_RAIL_MAX_WIDTH,
resolveWidth: resolveTabRailWidth,
+11 -1
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate
here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';var W=Number(A.tabRailWidth);if(V&&Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';}</script>
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -1903,6 +1903,16 @@
<option value="vertical">Vertical (side rail)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail detail rows created working idle status pill simple">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Rows</span>
<span class="set-row-desc">Detailed rows carry the home screen's per-session line (created, how long it has been working or idle) and a status pill. Needs ~280px of rail; a rail narrower than 240px drops back to simple rows.</span>
</div>
<select id="appSettingsTabRailDetail" class="set-select">
<option value="rich">Detailed</option>
<option value="simple">Simple (name only)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Width</span>
+7 -2
View File
@@ -1976,8 +1976,13 @@ Object.assign(CodemanApp.prototype, {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
input.value = '';
input.blur();
// Cancel, never commit. This used to clear the field and blur, and the
// blur handler commits — so Escape RENAMED the session to an empty
// string (measured: the tab fell back to its folder name and the server
// stored ""), in every layout. cancelRename() marks the edit
// invalidated, so the blur that follows the input's removal is a no-op.
e.preventDefault();
cancelRename();
}
});
},
+48 -5
View File
@@ -403,9 +403,14 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTabOrientation').value =
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
width: settings.tabRailWidth ?? defaults.tabRailWidth ?? 256,
// Same default resolution as applyTabRailWidth(): a rail that has never
// been sized shows the width it is actually rendering at, which for
// detailed rows is the Wide preset rather than 256.
width: settings.tabRailWidth ?? defaults.tabRailWidth ?? this._defaultTabRailWidth?.() ?? 256,
}) ?? 256;
this.syncTabRailWidthSetting?.(tabRailWidth);
document.getElementById('appSettingsTabRailDetail').value =
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
@@ -2041,6 +2046,7 @@ Object.assign(CodemanApp.prototype, {
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -2443,6 +2449,7 @@ Object.assign(CodemanApp.prototype, {
tabTwoRows: false,
tabOrientation: 'horizontal',
tabRailWidth: 256,
tabRailDetail: 'rich',
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
cjkInputEnabled: false,
@@ -2701,6 +2708,14 @@ Object.assign(CodemanApp.prototype, {
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
root.setAttribute('data-tab-orientation', orientation);
// Row detail rides on its OWN attribute, exactly like the sidebar's
// data-sidebar-detail: every html[data-tab-orientation='vertical'] rule in
// styles.css keeps matching both variants untouched, and the gate in app.js
// reads one attribute instead of re-parsing localStorage per tab.
const previousDetail = root.dataset.tabRailDetail || 'rich';
const detail = (settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich') === 'simple' ? 'simple' : 'rich';
root.dataset.tabRailDetail = detail;
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
const headerHost = document.getElementById('sessionTabsHost');
@@ -2718,13 +2733,35 @@ Object.assign(CodemanApp.prototype, {
const settleRailWidth =
options.settleRailWidth === true && (orientation === 'vertical' || previous !== orientation);
this.applyTabRailWidth?.({ settle: settleRailWidth });
if (previous !== orientation) {
const orientationChanged = previous !== orientation;
// A detail flip counts as a change on its own: simple ⟷ detailed leaves the
// orientation on 'vertical' both times, and the stamps line is emitted by
// the row template, not toggled by CSS — same reasoning as the sidebar's
// detail half in applySessionListLayout(). Taller rows also move every
// connector anchored to a tab rect.
const changed = orientationChanged || previousDetail !== detail;
if (orientationChanged) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.fitAddon?.fit();
this._fullRenderSessionTabs?.();
}
// applyTabWrapSettings() is the ONE owner of tabs-show-folder and is
// rail-aware, so it has to run AFTER the two attributes above — the
// applySessionListLayout() call that precedes this one on the settings-save
// path ran while data-tab-rail-detail still held the old value. It
// re-renders by itself when the folder row appears or disappears, which is
// why the render below is skipped in that case rather than doubled.
const prevTall = this._tallTabsEnabled;
if (changed) this.applyTabWrapSettings?.();
if (changed) {
if (prevTall === this._tallTabsEnabled) this._fullRenderSessionTabs?.();
this._updateConnectionLinesImmediate?.();
this._refreshHomeSessionsIfVisible?.();
}
// Only detailed rows carry stamps that go stale with no event behind them.
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
// on paths where nothing re-rendered (boot with the layout already applied).
if (this.isRichTabRows?.()) this._startSidebarRichClock?.();
else this._stopSidebarRichClock?.();
},
applyTabWrapSettings() {
@@ -2746,7 +2783,13 @@ Object.assign(CodemanApp.prototype, {
const twoRows = !sidebar && deviceType === 'desktop'
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
: false;
const showFolder = sidebar || twoRows;
// The DETAILED vertical rail is the third tall-row surface, for the same
// reason as the sidebar: it is a docked column with a row per session, and
// the stamps line below the name says nothing about WHICH project the
// session is in. Read from the applied attribute, which applyTabOrientation()
// has already written (app.js calls it before this).
const railRich = this.isTabRailRich?.() === true;
const showFolder = sidebar || twoRows || railRich;
const prevTallTabs = this._tallTabsEnabled;
this._tallTabsEnabled = showFolder;
const tabsEl = document.getElementById('sessionTabs');
@@ -2960,7 +3003,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily',
'language',
+58 -17
View File
@@ -17217,19 +17217,31 @@ html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
display: none !important;
}
/* --- Rich rows (sessionListLayout 'sidebar-rich') ----------------------- */
/* --- 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
difference is that each row also carries the line the desktop home rail and
the phone overview carry — when the session was first created, how long it
has been in the state it is in, and a status pill.
Everything here is scoped to html[data-sidebar-detail="rich"], which
The VERTICAL TAB RAIL is the second surface that draws those rows (it is a
docked column too, and #sessionTabs is the same element re-parented into it),
so every rule below carries a rail twin as an extra COMMA-GROUPED selector.
Deliberately not :is(): an :is() list takes its most specific argument's
specificity, which would silently raise the sidebar arm from (0,3,1) to the
rail arm's (0,5,1) and let these paint rules outrank things they never used
to. Grouped selectors each keep their own weight.
Sidebar rules are scoped to html[data-sidebar-detail="rich"], which
applySessionListLayout() only ever sets to 'rich' while data-session-list is
'sidebar'. `.tab-meta` is emitted by the row template exclusively in that
mode, so these rules have nothing to match anywhere else — the display:none
below is the second lock, not the mechanism. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta {
'sidebar'; rail rules to html[data-tab-orientation='vertical']
[data-tab-rail-detail='rich']:not(.tab-rail-compact), so a rail dragged below
240px drops back to simple rows the same way the collapsed sidebar does.
`.tab-meta` is emitted by the row template exclusively in those modes, so
these rules have nothing to match anywhere else — the display:none below is
the second lock, not the mechanism. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta {
display: flex;
align-items: center;
gap: 0.35em;
@@ -17251,33 +17263,53 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-meta {
display: none;
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-item {
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-item,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-item {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-key {
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-key,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-key {
margin-right: 0.35em;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.06em;
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-sep {
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-sep,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-sep {
opacity: 0.45;
}
/* Narrower than the detailed default (the .tab-rail-tight class,
_setTabRailWidth): the created stamp is dropped rather than shown as
"CREA…". Its value survives in the row's title attribute either way. */
html.tab-rail-tight[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-created,
html.tab-rail-tight[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-sep {
display: none;
}
/* On a rail narrower than the detailed default, the state duration is the last
thing that should go: it is the number the row is sorted by, and the pill
next to it is only a word. The created stamp ellipsizes instead. */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-since {
flex-shrink: 0;
}
/* While a session is actually doing something, how long it has been doing it is
what the eye should land on — same emphasis the home rail gives it. */
html[data-sidebar-detail="rich"] .session-sidebar .session-tab.tab-state-working .tab-meta-since {
html[data-sidebar-detail="rich"] .session-sidebar .session-tab.tab-state-working .tab-meta-since,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-working .tab-meta-since {
color: var(--green);
opacity: 0.95;
}
/* Pushed hard right and never shrinking, so the stamps ellipsize before the
status word does. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill {
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill {
flex-shrink: 0;
margin-left: auto;
padding: 0.1em 0.5em;
@@ -17294,19 +17326,23 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill {
/* Same three colors as every other session surface: red means a question is
pending, yellow means it wants input, green means work is happening. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--needs,
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--error {
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--error,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--needs,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--error {
background: color-mix(in srgb, var(--red) 18%, transparent);
border-color: color-mix(in srgb, var(--red) 45%, transparent);
color: var(--red);
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--waiting {
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--waiting,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--waiting {
background: color-mix(in srgb, var(--yellow) 18%, transparent);
border-color: color-mix(in srgb, var(--yellow) 45%, transparent);
color: var(--yellow);
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working {
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--working {
background: color-mix(in srgb, var(--green) 15%, transparent);
border-color: color-mix(in srgb, var(--green) 40%, transparent);
color: var(--green);
@@ -17314,7 +17350,8 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working {
/* Muted one step further than the idle dot: the pill is a block of color, so it
reads louder than a 9px dot at the same mix. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle {
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--idle {
background: color-mix(in srgb, var(--green) 7%, transparent);
border-color: color-mix(in srgb, var(--green) 18%, var(--border));
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
@@ -17329,7 +17366,8 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle {
then just a status dot and its badges. Without the guard, `align-items:
flex-start` and a 0.15rem top margin on .tab-status would push that dot off
the centre line of every row in the rail. */
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab {
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
align-items: flex-start;
padding: 0.45rem 0.5rem;
}
@@ -17338,7 +17376,10 @@ html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sideba
three-line one it drifts low, so pin it to the name it acts on. */
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-actions,
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-number,
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-status {
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-status,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name-row > .tab-actions,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-number,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-status {
margin-top: 0.15rem;
}
+26 -1
View File
@@ -70,6 +70,12 @@ Object.assign(CodemanApp.prototype, {
const wasCompact = root.classList.contains('tab-rail-compact');
const compact = resolved < 240;
root.classList.toggle('tab-rail-compact', compact);
// Second, softer threshold, CSS-only: a detailed row carries two stamps and
// below ~288px the created one ellipsizes to "CREA…", which says nothing.
// It is dropped there instead, leaving the state duration (the number the
// list is ordered by) and its pill intact. No re-render — unlike the rows
// themselves, this is a display toggle on markup that is already there.
root.classList.toggle('tab-rail-tight', resolved < 288);
if (wasCompact !== compact) this._fullRenderSessionTabs?.();
const handle = document.getElementById('tabRailResizeHandle');
if (handle) {
@@ -144,9 +150,28 @@ Object.assign(CodemanApp.prototype, {
}, 150);
},
/**
* The width a rail gets when the user has never picked one.
*
* Detailed rows carry a third line ("created 3d ago · working 12m" plus a
* status pill) and at 256px that line ellipsizes before it is finished — the
* same reason the rich SIDEBAR is 300px and the simple one 260px. 320px is
* the existing Wide preset, so a fresh detailed rail lands on a named choice
* rather than reading "Custom" in the settings select.
*
* Only the DEFAULT moves: a width the user has actually chosen (stored) is
* never overridden, and dragging the rail narrower is never fought — below
* 240px the rows drop back to simple ones on their own.
*/
_defaultTabRailWidth() {
const rich = document.documentElement.dataset.tabRailDetail !== 'simple';
if (rich) return window.CodemanTabRail?.RICH_DEFAULT_WIDTH ?? 320;
return window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
},
applyTabRailWidth(options = {}) {
const settings = this.loadAppSettingsFromStorage();
const requested = settings.tabRailWidth ?? window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
const requested = settings.tabRailWidth ?? this._defaultTabRailWidth();
const preferred = window.CodemanTabRail?.resolveWidth({ width: requested }) ?? 256;
if (options.settle) this._claimTabRailResize();
const resolved = this._setTabRailWidth(preferred);
+1
View File
@@ -997,6 +997,7 @@ export const SettingsUpdateSchema = z
tabTwoRows: z.boolean().optional(),
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
tabRailWidth: z.number().int().min(208).max(360).optional(),
tabRailDetail: z.enum(['simple', 'rich']).optional(),
/**
* Session list layout. Display key (per-device).
* 'header' = horizontal tab strip
+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"]');
});
});