Merge pull request #313 from Ark0N/feat/sidebar-rich

feat(sidebar): add a rich session sidebar that carries the home screen's row detail
This commit is contained in:
Ark0N
2026-08-18 21:14:53 +02:00
committed by GitHub
9 changed files with 682 additions and 24 deletions
+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
@@ -3687,6 +3687,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(),