feat(rail): sort the vertical tab rail by activity, and give its rows the home screen's card

The vertical rail lists exactly the sessions both home screens list, so it now
answers their question the same way instead of showing the raw tab order.

Order: new per-device `tabRailSort` (App Settings -> Appearance -> Tabs ->
Vertical Rail Order, default "By activity"). It runs `CodemanSessionOrder` over
rows classified by `_mobileOverviewState`, i.e. literally the home screens'
comparator, `lastSubmitAt`-anchored running group included.

It is applied as the flex `order` property, never by reordering the DOM.
`#sessionTabs` stays in `sessionOrder`, which is what keeps the Alt+N badge
honest (it names a shortcut, not a row position, so it deliberately does NOT
run 1,2,3 down a sorted rail), and keeps drag-and-drop, the arrow-key walk, the
sidebar filter and `_scrollActiveTabIntoView()` all reading the list they
always read. A session changing state then moves one inline style instead of
forcing the full rebuild that would restart every card's animation on every SSE
tick. The incremental render path re-applies it, since a state flip adds no tab
and never reaches the full rebuild, and an empty string is what clears it when
sorting stops. Web tabs are pinned past the cards by a CSS `order: 9999`, since
`renderWebviewTabs()` emits the same markup for every layout and the flex
default of 0 would interleave them. Drag is switched off while sorting (the
drop rewrites `sessionOrder` correctly and the sort puts the card straight
back, so the affordance would be a lie); 'manual' is the way back.

Cards: detailed rail rows become bordered cards on `--bg-card`, with the stamps
line on its own full-width row and the pill at its right end. The state dot
goes 6px to 9px, keeps its orbiting ring while working and gains the green
halo; idle mutes toward `--text-muted` as the home rail does. Needs/error/
waiting reuse `home-sessions-blink-red`/`-yellow` rather than a second copy.

These card rules are RAIL-SCOPED and deliberately absent from the comma-grouped
selectors that carry both vertical surfaces: the rail is an occasional,
resizable list you scan, while the sidebar is a permanently-docked nav column
where 20 stacked cards read as a wall. Every state-dot rule also excludes
`.tab-alert-action`/`.tab-alert-idle` by hand, because those alert rules are
only (0,3,0) and these are (0,5,1)+.

Lines: the lineage bracket already drew in the rail, but its track sat 6px from
the left edge, so half of its 11px outer glow was clipped by the window frame
and it read as a thread pinned to the frame. It now runs at 10px, mid-channel
in the gutter the rail already reserves.

Tests: test/tab-rail-order.test.ts (17) drives the real `isTabRailSorted()` and
`_tabRailSortOrder()` out of app.js, covering the row model (a WORKING row
ranked by `lastSubmitAt`, which would otherwise rank every running turn as
freshly started and fail no rendering test), the Alt+N badge, and the opt-out.

Verified in Chromium across sorted/manual/simple/header-strip/sidebar with the
setting flipped at runtime: no page errors, and the header strip and sidebar
render byte-identically to before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-12 06:09:28 +02:00
parent 02b0e27898
commit 8b23f3e260
8 changed files with 635 additions and 6 deletions
+94 -1
View File
@@ -4027,6 +4027,71 @@ class CodemanApp {
return this.isSessionSidebarRich() || this.isTabRailRich();
}
/**
* True when the VERTICAL TAB RAIL orders its cards the way both home screens
* do — blocked on you first, then running longest-first, then quiet
* most-recently-quiet first (`CodemanSessionOrder`, constants.js) — instead of
* leaving them in the user's tab order.
*
* Read off <html> like the other two rail gates, because the render loop asks
* it once per pass and getSessionListLayout() re-parses localStorage.
* `tabRailSort: 'manual'` is the opt-out, and it is what a user who reorders
* by hand wants: a self-sorting list cannot also be drag-reorderable, so
* setupTabDragHandlers() drops the drag affordance while this is on rather
* than letting a card snap back to where the sort puts it.
*
* Deliberately NOT gated on `isTabRailRich()`: a simple rail lists the same
* sessions and answers the same question, it just says less about each one.
*/
isTabRailSorted() {
const root = document.documentElement;
return root.getAttribute('data-tab-orientation') === 'vertical' && root.dataset.tabRailSort === 'activity';
}
/**
* Visual position per session id for the sorted rail, or null when the rail is
* not sorting.
*
* The sort is applied as the flex `order` property, NOT by reordering the DOM.
* That is the whole design: `#sessionTabs` stays in `sessionOrder`, so
* drag-and-drop, the Alt+N badges, the arrow-key walk, the sidebar filter and
* `_scrollActiveTabIntoView()` all keep reading the list they have always
* read, and a session changing state moves one inline style instead of
* forcing the full rebuild that would restart every card's animation.
*
* Rows are classified by `_mobileOverviewState()` and compared by
* `CodemanSessionOrder` — the same two helpers both home screens use, so the
* rail cannot disagree with them about what "working" means or what sorts
* first. `orderIndex` is the tab-strip position, which the comparator uses as
* its deterministic final tiebreak.
*
* Guarded like every other cross-file consumer: a stale cached constants.js or
* mobile-overview.js degrades to tab order rather than taking the strip down.
*
* @param {Array<string>} ids live session ids, in tab order
* @returns {Map<string, number>|null}
*/
_tabRailSortOrder(ids) {
if (!this.isTabRailSorted()) return null;
if (!window.CodemanSessionOrder || typeof this._mobileOverviewState !== 'function') return null;
const rows = [];
for (let i = 0; i < ids.length; i++) {
const session = this.sessions.get(ids[i]);
if (!session) continue;
rows.push({
id: ids[i],
state: this._mobileOverviewState(session, this.pendingHooks?.get(ids[i])),
lastActivityAt: Number(session.lastActivityAt) || 0,
lastSubmitAt: Number(session.lastSubmitAt) || 0,
orderIndex: i,
});
}
const sorted = window.CodemanSessionOrder.sort(rows);
const out = new Map();
for (let i = 0; i < sorted.length; i++) out.set(sorted[i].id, i);
return out;
}
/**
* True where the sidebar is a MODAL off-canvas drawer over the terminal
* instead of a docked column.
@@ -4648,11 +4713,21 @@ class CodemanApp {
// 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.isRichTabRows();
// Sorted vertical rail: a state change moves a card, and this is the path
// that sees one — a session going working→idle never adds or removes a
// tab, so the full rebuild below is not reached. Recomputed per pass for
// the same reason the rich meta line is: the order IS the state.
const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));
// Incremental update - only modify changed properties
for (const [id, session] of this.sessions) {
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
if (!tab) continue;
// An empty string clears the property, which is also what un-sorts the
// rail when the setting (or the layout) flips without a full rebuild.
const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';
if (tab.style.order !== railOrder) tab.style.order = railOrder;
// A web tab owns the active state while one is open. activeSessionId stays
// set (the terminal keeps streaming underneath, and switching back is
// instant): only the highlight moves. Without this the debounced render
@@ -4959,10 +5034,17 @@ class CodemanApp {
// Read once, not per session: isRichTabRows() touches the DOM and
// this loop runs for every tab on every full rebuild.
const richRows = this.isRichTabRows();
// The sorted vertical rail (tabRailSort) moves cards with the flex `order`
// property and leaves this loop iterating tab order, so the Alt+N badge
// below still counts the strip, not the sorted list. Null in every other
// layout, and the tabs then carry no inline order at all — the header
// strip's markup is byte-identical to before.
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
if (!session) continue; // Skip if session was removed
const railOrderStyle = railSortOrder?.has(id) ? ` style="order:${railSortOrder.get(id)}"` : '';
// See the note in the incremental path: a web tab owns the active highlight
// while one is open, even though activeSessionId stays set.
@@ -5016,7 +5098,7 @@ class CodemanApp {
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -5227,6 +5309,17 @@ class CodemanApp {
const container = this.$('sessionTabs');
const tabs = container.querySelectorAll('.session-tab[data-id]');
// A self-sorting list cannot also be hand-ordered: the drop below rewrites
// sessionOrder correctly, the sort then puts the card straight back where it
// was, and the user is left dragging a row that refuses to move. Drop the
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
// order this list is no longer showing.
if (this.isTabRailSorted()) {
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
return;
}
tabs.forEach(tab => {
tab.setAttribute('draggable', 'true');
+7 -1
View File
@@ -342,7 +342,13 @@ const LINEAGE_DIP_MAX_PX = 64;
// apart bled into one thick band instead of reading as three separate lines.
const LINEAGE_SIBLING_STEP_PX = 8;
const LINEAGE_STRIP_TOLERANCE_PX = 4;
const LINEAGE_VERTICAL_TRACK_INSET_PX = 6;
// How far the vertical bracket sits in from the rail's left edge. It has to
// clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
// glow, so a track at 6px had half of that glow clipped away and the arc read
// as a thin thread pinned to the window frame. The rail reserves the channel
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
// computeLineagePath still clamps the track to stay left of both tabs.
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
+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';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>
<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';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';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';document.documentElement.dataset.tabRailSort='activity';}</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)}
@@ -1944,6 +1944,16 @@
<option value="simple">Simple (name only)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Order</span>
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. Alt+1..9 always follows the tab order either way.</span>
</div>
<select id="appSettingsTabRailSort" class="set-select">
<option value="activity">By activity (home screen order)</option>
<option value="manual">Manual (drag to reorder)</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>
+14 -2
View File
@@ -431,6 +431,8 @@ Object.assign(CodemanApp.prototype, {
this.syncTabRailWidthSetting?.(tabRailWidth);
document.getElementById('appSettingsTabRailDetail').value =
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
document.getElementById('appSettingsTabRailSort').value =
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
@@ -2097,6 +2099,7 @@ Object.assign(CodemanApp.prototype, {
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -2500,6 +2503,7 @@ Object.assign(CodemanApp.prototype, {
tabOrientation: 'horizontal',
tabRailWidth: 256,
tabRailDetail: 'rich',
tabRailSort: 'activity',
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
cjkInputEnabled: false,
@@ -2766,6 +2770,14 @@ Object.assign(CodemanApp.prototype, {
const detail = (settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich') === 'simple' ? 'simple' : 'rich';
root.dataset.tabRailDetail = detail;
// Row ORDER rides on a third attribute, for the same reason detail rides on
// its own: a sort flip leaves orientation on 'vertical' both times, and the
// order is applied as an inline `order` the render paths emit, not by CSS
// that could just re-match. `isTabRailSorted()` (app.js) reads this.
const previousSort = root.dataset.tabRailSort || 'activity';
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
root.dataset.tabRailSort = sort;
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
const headerHost = document.getElementById('sessionTabsHost');
@@ -2789,7 +2801,7 @@ Object.assign(CodemanApp.prototype, {
// 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;
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
if (orientationChanged) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.fitAddon?.fit();
@@ -3062,7 +3074,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily',
'language',
+166
View File
@@ -17754,6 +17754,172 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
margin-top: 0.15rem;
}
/* --- Detailed rail rows wear the HOME SCREEN's card language -------------- */
/* The desktop home rail (.home-sessions, home-sessions.js) and this rail list
the same sessions with the same three lines — name, project, "created 3d ago
· working 12m" + pill — so they now LOOK the same too: a bordered card per
session instead of a flat strip row, a 9px state dot (pulsing and ringed
while the session works), the name at full weight, and the stamps line
wrapped onto its own full-width row with the pill parked at its right end.
⚠ RAIL ONLY, never the shared comma-grouped selectors above. The detailed
SIDEBAR draws the same meta line and is deliberately left flat: it is a
permanently-docked navigation column that sits next to the terminal all day,
and 20 stacked cards there read as a wall. Widening one of those grouped
rules instead of adding this block is what would silently restyle it.
⚠ The paint rules here are (0,5,1)-(0,6,1), so they outrank the plain
`.session-tab .tab-status.idle` class rules (0,2,0) — which is the point —
but they must NOT outrank the alert rules that turn a dot red or yellow when
a session is blocked on a human. Those are (0,3,0), so every dot rule below
excludes the two alert classes by hand rather than relying on the cascade.
State and alert normally agree (_mobileOverviewState reads the same pending
hooks the alert does), so this is a guard against them drifting, not a fix
for a known disagreement. */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tabs {
gap: 0.35rem;
padding-top: 0.5rem;
}
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
/* The stamps line rides a wrapped full-width row, exactly as on the home rail:
that hands the whole width of the card to the session name, which is what
stops a `w34-claudeman: mindreading` ellipsizing into `w34-claudeman: …`. */
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border-radius: 10px;
background: var(--bg-card);
border: 1px solid var(--border);
}
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab:hover {
background: var(--bg-hover);
border-color: rgba(34, 197, 94, 0.35);
color: var(--text);
}
/* The name is what tells one card from another, so it carries the weight the
home rail gives it rather than the strip's dim 0.75rem label. */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name {
color: var(--text);
font-weight: 600;
/* One step up from the shared vertical-list size, which is 12px because that
is what the SIDEBAR has always been; a card row has the height for the
~13px the home rail renders at. Still driven by the same setting (App
Settings → Session Name Font Size), so it moves with it. */
font-size: calc(var(--session-sidebar-name-font-size, 12px) + 1px);
/* Three lines rather than two: the card is wrapped, so a long name costs
height instead of pushing the pill or the stamps off the row. */
-webkit-line-clamp: 3;
line-clamp: 3;
}
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-folder {
font-size: 0.66rem;
margin-top: 0.1rem;
}
/* The state line gets the full width of the card (`flex: 0 0 100%` wraps it),
so the stamps stop competing with the gear/close column for the sliver of
room left beside the name. */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta {
flex: 0 0 100%;
margin-top: 0;
font-size: 0.64rem;
opacity: 0.75;
}
/* Bigger than the strip's 6px pip, because a card row has the room and the dot
is the one thing on it readable at a glance. */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-status {
width: 9px;
height: 9px;
margin-top: 0.3rem;
}
/* Idle is green, but a MUTED green — same mix the home rail uses, for the same
reason: a glance down the rail must separate "running right now" from
"sitting there" without reading a word. */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
.tab-rail
.session-tab.tab-state-idle:not(.tab-alert-action):not(.tab-alert-idle)
.tab-status {
background: color-mix(in srgb, var(--green) 42%, var(--text-muted));
}
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
.tab-rail
.session-tab.tab-state-done:not(.tab-alert-action):not(.tab-alert-idle)
.tab-status {
background: var(--text-muted);
opacity: 0.5;
}
/* The working halo. The orbiting ring is the shared `.tab-status.busy::after`
already declared above — only its inset moves, to clear the wider dot. */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
.tab-rail
.session-tab.tab-state-working:not(.tab-alert-action):not(.tab-alert-idle)
.tab-status {
background: var(--green);
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent);
}
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
.tab-rail
.session-tab
.tab-status.busy::after {
inset: -4px;
border-width: 2px;
}
/* Card accents: the same three colours as every other session surface, and the
same two blinks the home rail runs. `tab-alert-*` draws its own ::before ring
on top of this for the sessions that are genuinely blocked on a human; these
border accents are the calmer, always-on half. */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-needs,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-error {
border-color: color-mix(in srgb, var(--red) 50%, transparent);
animation: home-sessions-blink-red 2.5s ease-in-out infinite;
}
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-waiting {
border-color: color-mix(in srgb, var(--yellow) 50%, transparent);
animation: home-sessions-blink-yellow 3.5s ease-in-out infinite;
}
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-working {
border-color: color-mix(in srgb, var(--green) 35%, transparent);
}
/* No `.active` rule here on purpose: `.session-tab.active` (and its per-skin
twin) already paints background, border and box-shadow with !important, so a
card rule would be dead weight that reads as if it were doing something. The
selected card keeps the accent its skin gives every selected tab, which is
what makes it legible against the card background the state accents above
paint on. */
@media (prefers-reduced-motion: reduce) {
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
animation: none !important;
}
}
/* --- Activity-sorted rail (tabRailSort) ---------------------------------- */
/* Visual order only: the DOM stays in the user's tab order, so drag-reorder,
Alt+N badges, the arrow-key walk and the sidebar filter all keep reading the
list they always read. `order` is set inline per tab by the render paths
(app.js `_tabRailSortOrder`).
Web tabs carry no session state to sort by and keep their place at the end of
the list, so they are pinned past every session card rather than being given
an inline order of their own — `renderWebviewTabs()` emits the same markup for
every layout and must stay that way. */
html[data-tab-orientation='vertical'][data-tab-rail-sort='activity'] .tab-rail .session-tab[data-webview-id] {
order: 9999;
}
/* --- Collapsed rail ---------------------------------------------------- */
/* Collapsed is a 44px icon rail, not "hidden": the ambient signal (status dot,
task/subagent/ultracode badges) is the whole point of mission control and
+9
View File
@@ -1255,6 +1255,15 @@ export const SettingsUpdateSchema = z
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
tabRailWidth: z.number().int().min(208).max(360).optional(),
tabRailDetail: z.enum(['simple', 'rich']).optional(),
/**
* Vertical rail row order. Display key (per-device).
* 'activity' = the home screens' order (CodemanSessionOrder): blocked on a
* human first, then running longest-first, then quiet
* most-recently-quiet first.
* 'manual' = the user's tab order, and the only value that leaves the
* rail drag-reorderable.
*/
tabRailSort: z.enum(['activity', 'manual']).optional(),
/**
* Session list layout. Display key (per-device).
* 'header' = horizontal tab strip