feat(sidebar): add a rich session sidebar that carries the home screen's row detail

Session List Layout gains a third option. The old "Left sidebar" becomes
"Left sidebar simple" and is unchanged down to the byte; the new "Left sidebar"
puts on each row what the desktop home rail and the phone overview already show:
when the session was first created, how long it has been in the state it is in,
and a status pill naming that state.

A docked column is not a tab strip. It has width to spare and a row per session
either way, and "name + folder" is the whole story a TAB can tell, not the whole
story there is. This is the information that was missing, and it already existed
one surface over.

Both sidebar values are the same layout, and both set data-session-list="sidebar";
the row detail rides on a separate data-sidebar-detail attribute. That split is
the load-bearing decision here: every one of the ~25 isSessionSidebarActive()
call sites and every html[data-session-list="sidebar"] rule in styles.css and
mobile.css keeps matching both variants without being touched. A third
data-session-list value would have meant auditing and editing all of them.

- Stored values: 'header', 'sidebar' (simple), 'sidebar-rich'. Anyone already on
  'sidebar' keeps exactly the layout they picked — the rename is label-only.
- State classification and the "how long has it been like this" anchor come from
  _mobileOverviewState() / _mobileOverviewSince(), not re-derived, so the three
  surfaces cannot disagree about what "working" means. A working pane repaints
  ~1/s, so its duration is measured from the turn's last Enter: a running turn
  reads "working 12m", not "0m".
- Stamps refresh in place on a 20s clock rather than by re-rendering — a rebuild
  would restart every load spinner and alert animation in the list, twice a
  minute. The clock runs only while rich rows are on screen, and is stopped from
  both render paths and from applySessionListLayout().
- The incremental render path updates the pill, the accent class and the since
  anchor; a tick alone cannot see a state change, and a new turn re-stamps
  lastSubmitAt without changing state.
- applySessionListLayout() now re-renders on a DETAIL change too. simple <-> rich
  leaves data-session-list on 'sidebar' both times, and the meta line is emitted
  by the row template rather than toggled by CSS, so the old layout-only test
  would have flipped the setting and repainted nothing.
- Width: 300px for the extra line. The collapsed 44px rail and the handheld
  drawer are both explicitly held back from it — the desktop rule is (0,3,1) and
  would otherwise out-specify mobile.css's (0,2,1) drawer base and pin a 320px
  phone's drawer to 300px.
- Missing/stale mobile-overview.js degrades to a row with no meta line rather
  than throwing and taking the whole tab strip down.

15 new tests cover the attribute split, the solo-window override, the
detail-change re-render, the row model, both render paths, the clock lifecycle
and the mobile width guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-18 16:26:34 +02:00
parent 5080390e2c
commit 98e37bf895
9 changed files with 682 additions and 24 deletions
+13
View File
@@ -0,0 +1,13 @@
---
'aicodeman': patch
---
Session List Layout gains a third option, "Left sidebar", whose rows carry the same per-session detail the home screen shows.
The sidebar previously had one row style: a name and a folder. That is the whole story a tab can tell, but a docked column is not a tab strip — it has width to spare and a row per session either way, and the information that was missing is exactly the information the desktop home rail and the phone overview already put on screen. So the new option lifts it onto the rows: when the session was first created, how long it has been in the state it is in, and a status pill naming that state.
- The old "Left sidebar" is now **"Left sidebar simple"** and is unchanged, down to the byte — the stored value stays `sidebar`, so anyone already using it keeps exactly the layout they chose. The new option is `sidebar-rich`.
- Both sidebar values are the SAME layout and both set `data-session-list="sidebar"`; row detail rides on a separate `data-sidebar-detail` attribute. That is deliberate: every `isSessionSidebarActive()` call site and every `html[data-session-list="sidebar"]` rule in styles.css and mobile.css keeps matching both, untouched.
- Which state a session is in, and which stamp measures it, come from `_mobileOverviewState()` / `_mobileOverviewSince()` rather than being re-derived — the sidebar, the home rail and the phone overview cannot disagree about what "working" means. A working row is measured from the turn's last Enter, not from its last repaint, so a running turn reads `working 12m` instead of `0m`.
- The stamps refresh in place on a 20s clock instead of re-rendering: a rebuild would restart every load spinner and alert animation in the list, twice a minute. The clock only runs while rich rows are on screen.
- The column widens to 300px for the extra line, and the collapsed 44px rail and the handheld drawer are explicitly held back from that width.
+249 -16
View File
@@ -481,6 +481,14 @@ const DEFAULT_SHORTCUTS = [
// CodemanApp Class — constructor and global state
// ═══════════════════════════════════════════════════════════════
/**
* How often the rich sidebar rewrites its relative stamps in place. Matches the
* two home screens (mobile-overview.js, home-sessions.js). Deliberately a local
* const and not a constants.js export: an undefined interval would make
* setInterval fire on every frame, and constants.js is cached independently.
*/
const SIDEBAR_RICH_CLOCK_MS = 20000;
class CodemanApp {
constructor() {
this.sessions = new Map();
@@ -3704,18 +3712,26 @@ class CodemanApp {
// ═══════════════════════════════════════════════════════════════
/**
* 'header' | 'sidebar'. Solo (detached single-session) windows are ALWAYS
* 'header': they show exactly one session, so a session list is noise — and
* #sessionTabs must never be parked inside the display:none <aside>, where
* updateTabOverflowMode() would measure 0/0 and the inline rename input would
* get zero geometry.
* 'header' | 'sidebar' | 'sidebar-rich'. Solo (detached single-session) windows
* are ALWAYS 'header': they show exactly one session, so a session list is
* noise — and #sessionTabs must never be parked inside the display:none
* <aside>, where updateTabOverflowMode() would measure 0/0 and the inline
* rename input would get zero geometry.
*
* The two sidebar values are the SAME layout — same docked column, same
* re-parented #sessionTabs, same filter box, same Alt+B toggle. They differ
* only in how much each row says, which is why the split rides on a separate
* attribute (see applySessionListLayout) instead of a third data-session-list
* value: every one of the ~25 isSessionSidebarActive() call sites, and every
* html[data-session-list="sidebar"] rule in styles.css and mobile.css, must
* keep matching both without being touched.
*/
getSessionListLayout() {
if (this.soloSessionId) return 'header';
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const layout = settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
return layout === 'sidebar' ? 'sidebar' : 'header';
return layout === 'sidebar' || layout === 'sidebar-rich' ? layout : 'header';
}
/**
@@ -3729,6 +3745,21 @@ class CodemanApp {
return document.documentElement.dataset.sessionList === 'sidebar';
}
/**
* True when the sidebar is showing the DETAILED rows: the home screen's
* per-session line ("created 3d ago · working 12m") plus a status pill.
*
* Read off <html> for the same reason as isSessionSidebarActive() — it is
* called once per tab in the render loop, and getSessionListLayout()
* re-parses localStorage on every call. Implies isSessionSidebarActive():
* data-sidebar-detail is only ever 'rich' while data-session-list is
* 'sidebar', both in applySessionListLayout() and in the pre-paint script.
*/
isSessionSidebarRich() {
const root = document.documentElement;
return root.dataset.sessionList === 'sidebar' && root.dataset.sidebarDetail === 'rich';
}
/**
* True where the sidebar is a MODAL off-canvas drawer over the terminal
* instead of a docked column.
@@ -3806,23 +3837,30 @@ class CodemanApp {
*/
applySessionListLayout() {
const mode = this.getSessionListLayout();
// 'sidebar' and 'sidebar-rich' are the same column; only row detail differs.
const sidebar = mode === 'sidebar' || mode === 'sidebar-rich';
const collapsed = this.isSessionSidebarCollapsed();
const prevMode = document.documentElement.dataset.sessionList;
const prevDetail = document.documentElement.dataset.sidebarDetail;
const tabsEl = document.getElementById('sessionTabs');
const headerHost = document.getElementById('sessionTabsHost');
const sidebarList = document.getElementById('sessionSidebarList');
if (!tabsEl || !headerHost || !sidebarList) return;
const host = mode === 'sidebar' ? sidebarList : headerHost;
const host = sidebar ? sidebarList : headerHost;
if (tabsEl.parentElement !== host) host.appendChild(tabsEl);
document.documentElement.dataset.sessionList = mode;
document.documentElement.dataset.sessionList = sidebar ? 'sidebar' : 'header';
// Detail is meaningless outside the sidebar, and must not linger as 'rich'
// there: the rows carry no meta line in the header strip, and a stale 'rich'
// would let the sidebar CSS style a strip that has nothing to style.
document.documentElement.dataset.sidebarDetail = mode === 'sidebar-rich' ? 'rich' : 'simple';
document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded';
tabsEl.setAttribute('aria-orientation', mode === 'sidebar' ? 'vertical' : 'horizontal');
tabsEl.setAttribute('aria-orientation', sidebar ? 'vertical' : 'horizontal');
const btn = document.getElementById('sidebarToggleBtn');
if (btn) {
btn.classList.toggle('btn-sidebar-toggle--hidden', mode !== 'sidebar');
btn.classList.toggle('btn-sidebar-toggle--hidden', !sidebar);
const label = collapsed ? 'Expand session sidebar' : 'Collapse session sidebar';
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
btn.setAttribute('aria-label', label);
@@ -3833,12 +3871,12 @@ class CodemanApp {
// "collapsed" means the drawer is closed.
const aside = document.getElementById('sessionSidebar');
if (aside) {
aside.classList.toggle('open', mode === 'sidebar' && !collapsed);
aside.classList.toggle('open', sidebar && !collapsed);
// A closed overlay drawer is only moved off screen by translateX(-100%);
// it keeps display:flex, so without this its filter box and ~4 tab stops
// per session stay in the Tab order and in the accessibility tree.
// NOT applied to the docked desktop rail — its rows are still clickable.
const hiddenDrawer = mode === 'sidebar' && collapsed && this._isSessionSidebarOverlay();
const hiddenDrawer = sidebar && collapsed && this._isSessionSidebarOverlay();
aside.toggleAttribute('inert', hiddenDrawer);
if (hiddenDrawer) aside.setAttribute('aria-hidden', 'true');
else aside.removeAttribute('aria-hidden');
@@ -3847,7 +3885,7 @@ class CodemanApp {
// The filter box only exists inside the sidebar; leaving a stale filter
// applied when the layout goes back to the header strip would hide sessions
// from the tab bar with no reachable control to clear it.
if (mode !== 'sidebar') {
if (!sidebar) {
this._sidebarFilter = '';
const filterInput = document.getElementById('sessionSidebarFilter');
if (filterInput) filterInput.value = '';
@@ -3863,13 +3901,21 @@ class CodemanApp {
// A layout flip alone still needs one render: the rows are rebuilt into the
// new host with the drag/keyboard handlers re-bound. Skipped when
// applyTabWrapSettings() already rendered for the folder-row change.
if (prevMode !== mode && prevTall === this._tallTabsEnabled) {
//
// The detail half of the test is not redundant: simple ⟷ rich leaves
// data-session-list on 'sidebar' both times, so comparing only that would
// flip the setting and repaint nothing until the next SSE tick — and the
// meta line is emitted by the row template, not toggled by CSS.
const layoutChanged =
prevMode !== document.documentElement.dataset.sessionList ||
prevDetail !== document.documentElement.dataset.sidebarDetail;
if (layoutChanged && prevTall === this._tallTabsEnabled) {
this._fullRenderSessionTabs();
}
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
// drops it in sidebar mode, but drop it here too so nothing paints wrapped
// for a frame before the next measure.
if (mode === 'sidebar') tabsEl.classList.remove('tabs-auto-wrap');
if (sidebar) tabsEl.classList.remove('tabs-auto-wrap');
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
// here too — not only at the render tails.
this.applySidebarFilter(this._sidebarFilter);
@@ -3880,6 +3926,9 @@ class CodemanApp {
if (document.getElementById('welcomeOverlay')?.classList.contains('visible')) {
this.showHomeSessions?.();
}
// Only the rich rows carry stamps that go stale with no event behind them.
if (this.isSessionSidebarRich()) this._startSidebarRichClock();
else this._stopSidebarRichClock();
}
toggleSessionSidebar() {
@@ -3970,6 +4019,152 @@ class CodemanApp {
this.updateSidebarCount();
}
// ═══════════════════════════════════════════════════════════════
// Rich sidebar rows (sessionListLayout === 'sidebar-rich')
// ═══════════════════════════════════════════════════════════════
/**
* Pill copy per state, matching the desktop home rail and the phone overview
* word for word. Duplicated rather than imported for the same reason those two
* duplicate it from each other: it is six words, and constants.js is served
* from cache independently of app.js — a shared map there could arrive stale
* or missing while this file is new. What is NOT duplicated is the part that
* can actually disagree: which state a session is IN, and which stamp measures
* it, both of which come from mobile-overview.js below.
*/
_sidebarRichPillLabel(state) {
return {
needs: 'needs you',
error: 'error',
waiting: 'waiting',
working: 'working',
idle: 'idle',
done: 'done',
}[state] || state;
}
/**
* 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.
*
* 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.
*
* 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
* take the whole tab strip down with it.
*/
_sidebarRichRow(id, session) {
if (typeof this._mobileOverviewState !== 'function') return null;
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
return {
state,
pill: this._sidebarRichPillLabel(state),
createdAt: Number(session.createdAt) || 0,
since: this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
};
}
/**
* The "created 3d ago · working 12m" line plus the status pill, as the third
* child of `.tab-info` (already a flex column, so no row-level wrapping is
* needed — unlike the home rail, whose pill rides a wrapped full-width line).
*
* Both stamps keep their raw epoch-ms in `data-tab-ts` so
* `_tickSidebarRichTimes()` can rewrite the text without rebuilding the row:
* a rebuild would restart the load spinner and every alert animation in the
* list, twice a minute, for nothing.
*
* Returns '' when there is no model, which is what keeps the header strip and
* the simple sidebar byte-identical to before.
*/
_sidebarRichMetaHTML(row) {
if (!row) return '';
const stamp = (key, ts, fmt, cls) => {
const text = this._sidebarRichStampText(ts, fmt);
const title = ts
? ` title="${escapeHtml(`${key === 'created' ? 'First created' : key}: ${new Date(ts).toLocaleString()}`)}"`
: '';
return `<span class="tab-meta-item ${cls}"${title}><span class="tab-meta-key">${escapeHtml(key)}</span><span data-tab-ts="${ts || 0}" data-tab-fmt="${fmt}">${escapeHtml(text)}</span></span>`;
};
// data-i18n-skip: relative times are generated text, and "created"/"idle"
// are the same generic words that mean something else on other surfaces.
const parts = [stamp('created', row.createdAt, 'ago', 'tab-meta-created')];
if (row.since) {
parts.push('<span class="tab-meta-sep" aria-hidden="true">\u00B7</span>');
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>`;
}
/** Same formatter as both home screens, so a duration is written the same way everywhere. */
_sidebarRichStampText(timestamp, format) {
return this._mobileOverviewStampText ? this._mobileOverviewStampText(timestamp, format) : '\u2014';
}
/**
* Incremental-render counterpart of `_sidebarRichMetaHTML()`. The stamps move
* on the clock, but the STATE can change between renders (a session starts
* working, a permission prompt lands), and that flips the pill, the accent
* class and which stamp the second slot is even showing.
*
* Rebuilds the meta line only when something it displays actually changed,
* because this runs for every session on every SSE tick.
*/
_updateSidebarRichRow(tab, id, session) {
const row = this._sidebarRichRow(id, session);
if (!row) return;
const prev = tab.dataset.tabState;
// The since ANCHOR moves without the state changing (each new turn re-stamps
// lastSubmitAt), so key the compare on both.
const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}`;
if (tab.dataset.tabMetaSig === sig) return;
tab.dataset.tabMetaSig = sig;
tab.dataset.tabState = row.state;
if (prev) tab.classList.remove(`tab-state-${prev}`);
tab.classList.add(`tab-state-${row.state}`);
const info = tab.querySelector('.tab-info');
if (!info) return;
const html = this._sidebarRichMetaHTML(row);
const existing = info.querySelector('.tab-meta');
if (existing) existing.outerHTML = html;
else info.insertAdjacentHTML('beforeend', html);
}
/**
* Rewrites the relative stamps in place. A session that is just sitting there
* emits no event at all, so without this its "idle 2m" would still read 2m an
* hour later — the one number in the list that has to move on its own.
*/
_startSidebarRichClock() {
if (this._sidebarRichClock) return;
this._sidebarRichClock = setInterval(() => {
if (!this.isSessionSidebarRich()) {
this._stopSidebarRichClock();
return;
}
this._tickSidebarRichTimes();
}, SIDEBAR_RICH_CLOCK_MS);
}
_stopSidebarRichClock() {
if (!this._sidebarRichClock) return;
clearInterval(this._sidebarRichClock);
this._sidebarRichClock = null;
}
_tickSidebarRichTimes() {
const container = this.$('sessionTabs');
if (!container) return;
for (const node of container.querySelectorAll('[data-tab-ts]')) {
const text = this._sidebarRichStampText(Number(node.dataset.tabTs) || 0, node.dataset.tabFmt);
if (node.textContent !== text) node.textContent = text;
}
}
// ═══════════════════════════════════════════════════════════════
// Session Tabs
// ═══════════════════════════════════════════════════════════════
@@ -4160,6 +4355,9 @@ class CodemanApp {
webTabsUnchanged;
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();
// Incremental update - only modify changed properties
for (const [id, session] of this.sessions) {
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
@@ -4232,6 +4430,21 @@ class CodemanApp {
statusEl.className = `tab-status ${status}`;
}
// Rich sidebar meta ("created 3d ago · working 12m" + pill). The stamps
// themselves move on _tickSidebarRichTimes(); this is here for the parts
// a tick cannot see — the state flipping, and with it the pill, the row
// accent and which stamp the second slot is measuring at all.
if (richRows) {
this._updateSidebarRichRow(tab, id, session);
} else if (tab.dataset.tabState) {
// Layout flipped away from rich without a full rebuild reaching this
// row yet: strip the line rather than leave a frozen stamp behind.
tab.querySelector('.tab-meta')?.remove();
tab.classList.remove(`tab-state-${tab.dataset.tabState}`);
delete tab.dataset.tabState;
delete tab.dataset.tabMetaSig;
}
// Update name if changed. #232: a description (the `: suffix` part of the
// name) is the whole tab label; the generated id lives in the tooltip. The
// compare targets the DISPLAY text, or a described tab would re-render on
@@ -4430,6 +4643,9 @@ class CodemanApp {
// into view replaces it.
const parts = [];
const tabOrder = this.sessionOrder;
// Read once, not per session: isSessionSidebarRich() touches the DOM and
// this loop runs for every tab on every full rebuild.
const richRows = this.isSessionSidebarRich();
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
@@ -4471,7 +4687,17 @@ class CodemanApp {
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
: (session.workingDir || '');
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}" 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)}"` : ''}>
// 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.
const richRow = richRows ? this._sidebarRichRow(id, session) : null;
const richMeta = this._sidebarRichMetaHTML(richRow);
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
const richData = richRow
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"`
: '';
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)}"` : ''}>
${_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>
@@ -4482,6 +4708,7 @@ class CodemanApp {
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
${richMeta}
</span>
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge}
@@ -4531,6 +4758,12 @@ class CodemanApp {
// innerHTML was rebuilt wholesale, so the sidebar filter classes are gone —
// re-apply them or filtered-out sessions flicker back on every SSE tick.
this.applySidebarFilter(this._sidebarFilter);
// Rows that carry self-staling stamps need the clock; rows that don't must
// not leave it running. Both directions matter — the layout can flip
// underneath a render, and a solo window forces 'header' regardless.
if (richRows) this._startSidebarRichClock();
else this._stopSidebarRichClock();
}
// Set up arrow key navigation for session tabs (accessibility)
+3 -2
View File
@@ -234,8 +234,9 @@
'Session List Layout': '会话列表布局',
'Header tab strip': '顶栏标签条',
'Left sidebar': '左侧边栏',
'Horizontal strip in the header, or a collapsible left sidebar (Alt+B).':
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。',
'Left sidebar simple': '左侧边栏(简洁)',
'Horizontal strip in the header, or a collapsible left sidebar (Alt+B). The rich sidebar carries the same per-session detail as the home screen.':
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
Panels: '面板',
+4 -3
View File
@@ -62,7 +62,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 L=JSON.parse(localStorage.getItem(k)||'{}').sessionListLayout;var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');document.documentElement.dataset.sessionList=(L==='sidebar'&&!solo)?'sidebar':'header';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebar='expanded';}</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 L=JSON.parse(localStorage.getItem(k)||'{}').sessionListLayout;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';}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';}</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)}
@@ -1843,11 +1843,12 @@
<div class="set-row has-field" data-search="session list layout sidebar tab strip vertical">
<div class="set-row-text">
<span class="set-row-label">Session List Layout</span>
<span class="set-row-desc">Horizontal strip in the header, or a collapsible left sidebar (Alt+B).</span>
<span class="set-row-desc">Horizontal strip in the header, or a collapsible left sidebar (Alt+B). The rich sidebar carries the same per-session detail as the home screen.</span>
</div>
<select id="appSettingsSessionListLayout" class="set-select">
<option value="header">Header tab strip</option>
<option value="sidebar">Left sidebar</option>
<option value="sidebar">Left sidebar simple</option>
<option value="sidebar-rich">Left sidebar</option>
</select>
</div>
<div class="set-row" data-search="tall tabs folder name two rows">
+13
View File
@@ -3659,6 +3659,19 @@ html[data-session-list="sidebar"] .session-sidebar {
padding-left: var(--safe-area-left);
}
/* The rich variant's desktop column is 300px (--sidebar-width-rich, styles.css)
and its selector carries one attribute MORE than the drawer base above —
(0,3,1) vs (0,2,1) — so without this it would win here and pin the drawer of
a 320px phone to 300px, leaving 20px of terminal behind it. How wide a drawer
may be is a viewport decision, never a row-detail one: match the specificity
and hand the width back. Row detail itself is kept — the stamps are as useful
on a phone as anywhere, and the drawer is wider than the rail they were
designed against. */
html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {
width: min(280px, 80vw);
flex: 0 0 auto;
}
html[data-session-list="sidebar"] .session-sidebar.open {
transform: translateX(0);
visibility: visible;
+4
View File
@@ -2649,6 +2649,10 @@ Object.assign(CodemanApp.prototype, {
// rules unchanged. Kept here rather than only in applySessionListLayout() so
// that a stray applyTabWrapSettings() call (this one is invoked from
// saveAppSettings and from the resize path) cannot leave the sidebar wrapped.
// Matches BOTH sidebar variants: isSessionSidebarActive() reads
// data-session-list, which applySessionListLayout() sets to 'sidebar' for
// 'sidebar' and 'sidebar-rich' alike. Row detail rides on a separate
// attribute and has no bearing on wrapping.
const sidebar = this.isSessionSidebarActive?.() === true;
// Two-row tabs disabled on mobile/tablet — not enough screen space
const twoRows = !sidebar && deviceType === 'desktop'
+134
View File
@@ -50,6 +50,7 @@
--header-height: 36px;
--toolbar-height: 42px;
--sidebar-width: 260px;
--sidebar-width-rich: 300px; /* detailed rows carry a stamps line as well */
--sidebar-width-collapsed: 44px; /* == --touch-target-min */
--sidebar-transition: 0.18s ease;
--glass-bg: rgba(31, 38, 48, 0.85);
@@ -16675,6 +16676,14 @@ html[data-session-list="sidebar"] .session-sidebar {
dropdowns and the inline rename input paintable in place. */
}
/* Rich rows carry a stamps line the simple rows do not, and at 260px
"created 3d ago · working 12m" ellipsizes before it is finished. Overridden
by the collapsed rule below, which is more specific and comes after. */
html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {
flex-basis: var(--sidebar-width-rich);
width: var(--sidebar-width-rich);
}
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar {
flex-basis: var(--sidebar-width-collapsed);
width: var(--sidebar-width-collapsed);
@@ -16797,6 +16806,131 @@ html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
display: none !important;
}
/* --- Rich rows (sessionListLayout 'sidebar-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
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 {
display: flex;
align-items: center;
gap: 0.35em;
min-width: 0;
margin-top: 0.15em;
font-size: 0.62rem;
font-family: monospace;
line-height: 1.3;
color: var(--text-muted);
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
}
/* A meta line can only be produced by the rich row template, but if one ever
survives into another layout (a render that lost a race with a settings flip)
it must not paint: the header strip has no room for it. */
.session-tab .tab-meta {
display: none;
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-item {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
html[data-sidebar-detail="rich"] .session-sidebar .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 {
opacity: 0.45;
}
/* 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 {
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 {
flex-shrink: 0;
margin-left: auto;
padding: 0.1em 0.5em;
border-radius: 999px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.95em;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
}
/* 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 {
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 {
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 {
background: color-mix(in srgb, var(--green) 15%, transparent);
border-color: color-mix(in srgb, var(--green) 40%, transparent);
color: var(--green);
}
/* 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 {
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));
}
/* Three lines of content per row instead of two, so give them room to breathe
and stop the row actions crowding the pill.
:not([data-sidebar="collapsed"]) is load-bearing, not decoration: the two
rules below are the only ones in this block that move geometry rather than
paint the meta line, and the collapsed 44px rail centres a row that is by
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 {
align-items: flex-start;
padding: 0.45rem 0.5rem;
}
/* The gear/detach/close column is centred against a two-line row; against a
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 {
margin-top: 0.15rem;
}
/* --- 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
+11 -2
View File
@@ -956,8 +956,17 @@ export const SettingsUpdateSchema = z
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
/** Session list layout: 'header' = horizontal tab strip, 'sidebar' = collapsible left sidebar. Display key (per-device). */
sessionListLayout: z.enum(['header', 'sidebar']).optional(),
/**
* Session list layout. Display key (per-device).
* 'header' = horizontal tab strip
* 'sidebar' = collapsible left sidebar, one compact row per session
* 'sidebar-rich' = same sidebar, each row carrying the home screen's detail
* (created/idle/working stamps + status pill)
* Both sidebar values render the SAME docked column and set
* data-session-list="sidebar"; they differ only in row detail, which rides
* on data-sidebar-detail. See applySessionListLayout() in app.js.
*/
sessionListLayout: z.enum(['header', 'sidebar', 'sidebar-rich']).optional(),
agentTeamsEnabled: z.boolean().optional(),
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
claudeModel: z.string().max(50).optional(),
+251 -1
View File
@@ -51,6 +51,25 @@ interface LayoutApp {
applySidebarFilter(query?: string): void;
_fullRenderSessionTabs(): void;
updateConnectionLines(): void;
isSessionSidebarRich(): 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;
_startSidebarRichClock(): void;
_stopSidebarRichClock(): void;
_tickSidebarRichTimes(): void;
_sidebarRichClock: ReturnType<typeof setInterval> | null;
pendingHooks?: Map<string, Set<string>>;
_mobileOverviewState?: (session: Record<string, unknown>, hooks?: Set<string>) => string;
_mobileOverviewSince?: (state: string, session: Record<string, unknown>) => { key: string; at: number } | null;
_mobileOverviewStampText?: (ts: number, fmt: string) => string;
}
interface RichRow {
state: string;
pill: string;
createdAt: number;
since: { key: string; at: number } | null;
}
/** The parts of index.html this feature touches, minus everything it does not. */
@@ -438,7 +457,7 @@ describe('session list layout wiring', () => {
// SettingsUpdateSchema is .strict() and this key is NOT in the PUT strip-list,
// so without the schema entry the server 400s the ENTIRE settings PUT and every
// unrelated setting silently stops persisting.
expect(SCHEMAS).toContain("sessionListLayout: z.enum(['header', 'sidebar']).optional()");
expect(SCHEMAS).toContain("sessionListLayout: z.enum(['header', 'sidebar', 'sidebar-rich']).optional()");
});
it('plumbs the setting through populate, collect, defaults and the display-key set', () => {
@@ -518,3 +537,234 @@ describe('session list layout wiring', () => {
expect(MOBILE_HANDLERS).toContain("e.target?.closest?.('.session-sidebar')");
});
});
/**
* The rich variant is the SAME sidebar with more on each row, and that is the
* whole reason it does not get its own `data-session-list` value: every one of
* the ~25 `isSessionSidebarActive()` call sites and every
* `html[data-session-list="sidebar"]` rule in styles.css and mobile.css has to
* keep matching it untouched. `data-session-list stays "sidebar"` below is the
* assertion that guards that, and it is the one to read first.
*/
describe('rich session sidebar', () => {
/**
* The row model is built from mobile-overview.js helpers, which the harness
* does not eval (it would drag the whole phone overview in for three
* functions). Stubbing them is also the sharper test: it pins exactly which
* shared helper each field comes from.
*/
function stubOverview(app: LayoutApp, state = 'working') {
app.pendingHooks = new Map();
app._mobileOverviewState = () => state;
app._mobileOverviewSince = (s, session) =>
s === 'working'
? { key: 'working', at: Number(session.lastSubmitAt) || 0 }
: { key: 'idle', at: Number(session.lastActivityAt) || 0 };
app._mobileOverviewStampText = (ts, fmt) => (ts ? `${fmt}:${ts}` : '—');
}
const SESSION = { createdAt: 1000, lastActivityAt: 5000, lastSubmitAt: 4000 };
it('data-session-list stays "sidebar" so every existing sidebar rule and call site still matches', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
expect(app.getSessionListLayout()).toBe('sidebar-rich');
app.applySessionListLayout();
// The load-bearing assertion: the layout attribute is NOT 'sidebar-rich'.
expect(win.document.documentElement.dataset.sessionList).toBe('sidebar');
expect(win.document.documentElement.dataset.sidebarDetail).toBe('rich');
expect(app.isSessionSidebarActive()).toBe(true);
expect(app.isSessionSidebarRich()).toBe(true);
// …and everything the simple sidebar does, it still does.
expect(tabsEl(win).parentElement?.id).toBe('sessionSidebarList');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(false);
expect(app._tallTabsEnabled).toBe(true);
});
it('marks the simple sidebar and the header strip as not rich', () => {
for (const layout of ['sidebar', 'header']) {
const { win, app } = boot({ stored: { sessionListLayout: layout } });
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sidebarDetail).toBe('simple');
expect(app.isSessionSidebarRich()).toBe(false);
}
});
it('forces a solo window back to the header strip, detail and all', () => {
// A detached window shows exactly one session: a list of it is noise, and
// #sessionTabs must never be parked inside the display:none <aside>.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' }, solo: 'sess-1' });
expect(app.getSessionListLayout()).toBe('header');
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sessionList).toBe('header');
expect(win.document.documentElement.dataset.sidebarDetail).toBe('simple');
expect(app.isSessionSidebarRich()).toBe(false);
});
it('re-renders when only the DETAIL changes, which the old layout-only test could not see', () => {
// simple ⟷ rich leaves data-session-list on 'sidebar' both times. The meta
// line is emitted by the row template, not toggled by CSS, so a missed
// re-render here means flipping the setting repaints nothing until the next
// SSE tick.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
app.applySessionListLayout();
(app._fullRenderSessionTabs as unknown as { mockClear(): void }).mockClear();
win.localStorage.setItem('codeman-app-settings', JSON.stringify({ sessionListLayout: 'sidebar-rich' }));
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sidebarDetail).toBe('rich');
expect(app._fullRenderSessionTabs).toHaveBeenCalled();
});
it('builds the row model from the shared overview helpers, not its own copy', () => {
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
const row = app._sidebarRichRow('s1', SESSION)!;
expect(row.state).toBe('working');
expect(row.pill).toBe('working');
expect(row.createdAt).toBe(1000);
// A working pane repaints ~1/s, so its duration is anchored on the turn's
// last Enter (lastSubmitAt), never on lastActivityAt.
expect(row.since).toEqual({ key: 'working', at: 4000 });
});
it('degrades to no meta line when mobile-overview.js is missing or stale', () => {
// iOS Safari serves old JS after a deploy. A missing helper must cost the
// stamps line, not the whole tab strip.
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
expect(app._sidebarRichRow('s1', SESSION)).toBeNull();
expect(app._sidebarRichMetaHTML(null)).toBe('');
});
it('renders both stamps and the pill, and parks raw epochs for the clock', () => {
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
const html = app._sidebarRichMetaHTML(app._sidebarRichRow('s1', SESSION));
expect(html).toContain('class="tab-meta"');
expect(html).toContain('>created<');
expect(html).toContain('>working<');
expect(html).toContain('tab-pill--working');
// Raw epoch-ms on the element is what lets the clock rewrite the text
// without a re-render — a re-render would restart every load spinner and
// alert animation in the list, twice a minute.
expect(html).toContain('data-tab-ts="1000" data-tab-fmt="ago"');
expect(html).toContain('data-tab-ts="4000" data-tab-fmt="for"');
// Generated relative times must not be handed to the translator.
expect(html).toContain('data-i18n-skip');
});
it('drops the second stamp when the session has never been active', () => {
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
app._mobileOverviewSince = () => null;
const html = app._sidebarRichMetaHTML(app._sidebarRichRow('s1', { createdAt: 1000 }));
expect(html).toContain('data-tab-fmt="ago"');
expect(html).not.toContain('data-tab-fmt="for"');
// The pill is not optional: it is the row's status word.
expect(html).toContain('tab-pill--working');
});
it('rewrites the stamps in place instead of re-rendering the row', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
app.applySessionListLayout();
tabsEl(win).innerHTML = `<div class="session-tab" data-id="s1"><span class="tab-info">${app._sidebarRichMetaHTML(
app._sidebarRichRow('s1', SESSION)
)}</span></div>`;
const metaBefore = tabsEl(win).querySelector('.tab-meta');
app._mobileOverviewStampText = (ts, fmt) => (ts ? `${fmt}:${ts}:later` : '—');
app._tickSidebarRichTimes();
expect(tabsEl(win).querySelector('.tab-meta')).toBe(metaBefore);
expect(metaBefore!.textContent).toContain('ago:1000:later');
});
it('updates the pill and the row accent when the state changes between renders', () => {
// The clock cannot see this: a state flip changes the pill, the accent class
// and which stamp the second slot is even measuring.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
app.applySessionListLayout();
tabsEl(win).innerHTML = '<div class="session-tab" data-id="s1"><span class="tab-info"></span></div>';
const tab = tabsEl(win).querySelector('.session-tab')!;
app._updateSidebarRichRow(tab, 's1', SESSION);
expect(tab.classList.contains('tab-state-working')).toBe(true);
expect(tab.querySelector('.tab-pill')!.textContent).toBe('working');
stubOverview(app, 'idle');
app._updateSidebarRichRow(tab, 's1', SESSION);
expect(tab.classList.contains('tab-state-working')).toBe(false);
expect(tab.classList.contains('tab-state-idle')).toBe(true);
expect(tab.querySelector('.tab-pill')!.textContent).toBe('idle');
// Idle is measured from the last byte the pane printed, not from a submit.
expect(tab.querySelector('[data-tab-fmt="for"]')!.getAttribute('data-tab-ts')).toBe('5000');
});
it('skips the DOM write when nothing the row displays has changed', () => {
// This runs for every session on every SSE tick.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
app.applySessionListLayout();
tabsEl(win).innerHTML = '<div class="session-tab" data-id="s1"><span class="tab-info"></span></div>';
const tab = tabsEl(win).querySelector('.session-tab')!;
app._updateSidebarRichRow(tab, 's1', SESSION);
const meta = tab.querySelector('.tab-meta');
app._updateSidebarRichRow(tab, 's1', SESSION);
expect(tab.querySelector('.tab-meta')).toBe(meta);
// …but a new turn re-stamps lastSubmitAt without changing the state, and
// that MUST still repaint: the duration is anchored on it.
app._updateSidebarRichRow(tab, 's1', { ...SESSION, lastSubmitAt: 9000 });
expect(tab.querySelector('.tab-meta')).not.toBe(meta);
expect(tab.querySelector('[data-tab-fmt="for"]')!.getAttribute('data-tab-ts')).toBe('9000');
});
it('runs the clock only while rich rows are on screen', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
app.applySessionListLayout();
expect(app._sidebarRichClock).toBeTruthy();
win.localStorage.setItem('codeman-app-settings', JSON.stringify({ sessionListLayout: 'sidebar' }));
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applySessionListLayout();
// A leaked interval would keep rewriting stamps in a list that no longer
// has any, forever, on every open tab.
expect(app._sidebarRichClock).toBeNull();
});
it('emits the meta line only in the rich row template, and only inside .tab-info', () => {
// .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 richMeta = this._sidebarRichMetaHTML(richRow);');
expect(APP).toContain('${richMeta}\n </span>');
});
it('plumbs the third option through the settings UI and the pre-paint script', () => {
expect(INDEX_HTML).toContain('<option value="sidebar">Left sidebar simple</option>');
expect(INDEX_HTML).toContain('<option value="sidebar-rich">Left sidebar</option>');
// Pre-paint must resolve BOTH sidebar values to the same layout attribute,
// or the first frame paints a header strip and then jumps.
expect(INDEX_HTML).toContain("(L==='sidebar'||L==='sidebar-rich')&&!solo");
expect(INDEX_HTML).toContain("dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple'");
for (const key of ['Left sidebar simple']) expect(I18N).toContain(`'${key}'`);
});
it('keeps the desktop rich width out of the handheld drawer', () => {
// styles.css scopes the 300px column with (0,3,1) — one attribute MORE than
// mobile.css's (0,2,1) drawer base — so without a matching override in
// mobile.css it wins there too and pins a 320px phone's drawer to 300px.
expect(STYLES_CSS).toContain('--sidebar-width-rich');
expect(STYLES_CSS).toContain('html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {');
expect(MOBILE_CSS).toContain('html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {');
});
});