Merge pull request #338 from Ark0N/feat/vertical-rail-detailed-rows

Vertical tab rail: detailed rows (created / working / status), plus a rename-cancel fix
This commit is contained in:
Ark0N
2026-08-25 19:12:57 +02:00
committed by GitHub
13 changed files with 538 additions and 44 deletions
+61 -13
View File
@@ -3829,6 +3829,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.
@@ -4002,7 +4034,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();
}
@@ -4119,13 +4151,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
@@ -4172,7 +4205,21 @@ class CodemanApp {
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
}
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(row.state)}">${escapeHtml(row.pill)}</span>`);
return `<span class="tab-meta" data-i18n-skip>${parts.join('')}</span>`;
// Both absolute stamps ALSO on the line itself, not only on the two items.
// Below 288px the rail hides `.tab-meta-created` (the `tab-rail-tight`
// rule), and a tooltip on a `display: none` element has no hover target —
// so without this the created stamp is not merely shrunk, it is gone with
// no way to ask for it. The pill and the gaps around the stamps are the
// hover targets that remain; an item's own title still wins over this one
// where the item is visible.
const lineTitle = [
row.createdAt ? `First created: ${new Date(row.createdAt).toLocaleString()}` : '',
row.since && row.since.at ? `${row.since.key}: ${new Date(row.since.at).toLocaleString()}` : '',
]
.filter(Boolean)
.join(' \u00B7 ');
const lineTitleAttr = lineTitle ? ` title="${escapeHtml(lineTitle)}"` : '';
return `<span class="tab-meta"${lineTitleAttr} data-i18n-skip>${parts.join('')}</span>`;
}
/** Same formatter as both home screens, so a duration is written the same way everywhere. */
@@ -4217,7 +4264,7 @@ class CodemanApp {
_startSidebarRichClock() {
if (this._sidebarRichClock) return;
this._sidebarRichClock = setInterval(() => {
if (!this.isSessionSidebarRich()) {
if (!this.isRichTabRows()) {
this._stopSidebarRichClock();
return;
}
@@ -4434,7 +4481,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}"]`);
@@ -4743,9 +4790,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);
@@ -4789,9 +4836,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}` : '';
+7 -1
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 = {}) {
@@ -185,7 +188,9 @@ function resolveTabRailKeyboardWidth(input = {}) {
let width;
if (input.key === 'Home') width = TAB_RAIL_MIN_WIDTH;
else if (input.key === 'End') width = TAB_RAIL_MAX_WIDTH;
else if (input.key === 'Enter') width = TAB_RAIL_DEFAULT_WIDTH;
// Enter resets to the caller's effective default (the rich rail's is the
// Wide preset, not 256 — see _defaultTabRailWidth); absent, the base default.
else if (input.key === 'Enter') width = Number(input.defaultWidth) || TAB_RAIL_DEFAULT_WIDTH;
else if (input.key === 'ArrowLeft' || input.key === 'ArrowRight') {
const direction = input.key === 'ArrowLeft' ? -1 : 1;
width = (Number(input.currentWidth) || TAB_RAIL_DEFAULT_WIDTH) + direction * (input.shiftKey ? 32 : 8);
@@ -686,6 +691,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)}
@@ -1925,6 +1925,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
@@ -2196,8 +2196,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();
}
});
},
+60 -5
View File
@@ -416,9 +416,18 @@ 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. The rich-aware
// default must come BEFORE the per-device defaults blob: the handheld
// blob carries tabRailWidth: 256, which applyTabRailWidth() never reads,
// so consulting it first showed a tablet's unsized rich rail as 256 while
// it rendered at 320 — and a routine Save then PERSISTED the 256.
width: settings.tabRailWidth ?? this._defaultTabRailWidth?.() ?? defaults.tabRailWidth ?? 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';
@@ -2055,6 +2064,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(
@@ -2457,6 +2467,7 @@ Object.assign(CodemanApp.prototype, {
tabTwoRows: false,
tabOrientation: 'horizontal',
tabRailWidth: 256,
tabRailDetail: 'rich',
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
cjkInputEnabled: false,
@@ -2715,6 +2726,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');
@@ -2732,13 +2751,43 @@ 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) {
// Mirror of applyTabWrapSettings()'s OWN render condition, which is
// `prevTallTabs !== undefined && prevTallTabs !== showFolder`: its first
// call ever only establishes the baseline and deliberately renders
// nothing. Reading an undefined previous value as "it rendered" skips
// BOTH renders and leaves the rows stale — reachable whenever this is the
// first call, i.e. when the pre-paint script threw and left the
// attributes on their fallbacks for applyTabOrientation() to correct.
const wrapRendered = prevTall !== undefined && prevTall !== this._tallTabsEnabled;
if (!wrapRendered) 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() {
@@ -2760,7 +2809,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');
@@ -2974,7 +3029,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',
+61 -17
View File
@@ -17275,19 +17275,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;
@@ -17309,33 +17321,56 @@ 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 stays reachable as the tooltip on the meta LINE
(`.tab-meta` carries both absolute stamps, _sidebarRichMetaHTML) — the title
on the hidden `.tab-meta-created` itself goes away with it, since a
`display: none` element has no hover target. */
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;
@@ -17352,19 +17387,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);
@@ -17372,7 +17411,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));
@@ -17387,7 +17427,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;
}
@@ -17396,7 +17437,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;
}
+45 -3
View File
@@ -70,7 +70,27 @@ Object.assign(CodemanApp.prototype, {
const wasCompact = root.classList.contains('tab-rail-compact');
const compact = resolved < 240;
root.classList.toggle('tab-rail-compact', compact);
if (wasCompact !== compact) this._fullRenderSessionTabs?.();
// 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) {
// The folder line is owned by applyTabWrapSettings(), whose railRich
// input reads the compact class this function just toggled — without
// re-running it, a rich rail dragged below 240px kept emitting folder
// rows (and, for a stored width < 240, kept them across reloads: the
// boot-time wrap pass runs before this function first applies the
// class). It re-renders only when the folder flag actually flipped, so
// cover the flip-without-folder-change case (a simple-detail rail
// crossing 240px still changes the row-action affordance) without
// rendering twice.
const prevTall = this._tallTabsEnabled;
this.applyTabWrapSettings?.();
const wrapRendered = prevTall !== undefined && this._tallTabsEnabled !== prevTall;
if (!wrapRendered) this._fullRenderSessionTabs?.();
}
const handle = document.getElementById('tabRailResizeHandle');
if (handle) {
handle.setAttribute('aria-valuemax', String(effectiveMax));
@@ -144,9 +164,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);
@@ -198,6 +237,7 @@ Object.assign(CodemanApp.prototype, {
key: event.key,
shiftKey: event.shiftKey,
currentWidth: this._getCurrentTabRailWidth(),
defaultWidth: this._defaultTabRailWidth?.(),
...this._getTabRailBounds(),
});
if (width === null || width === undefined) return;
@@ -240,7 +280,9 @@ Object.assign(CodemanApp.prototype, {
handle.addEventListener('dblclick', (event) => {
event.preventDefault();
this._claimTabRailResize();
const preferred = window.CodemanTabRail?.DEFAULT_WIDTH || 256;
// Rich-aware: resetting a detailed rail to 256 would land it below the
// 288px tight threshold and silently drop the created stamp.
const preferred = this._defaultTabRailWidth?.() ?? (window.CodemanTabRail?.DEFAULT_WIDTH || 256);
const effective = this._setTabRailWidth(preferred);
this._scheduleTabRailSettle(effective, preferred);
});
+1
View File
@@ -1096,6 +1096,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