mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
+249
-16
@@ -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)
|
||||
|
||||
@@ -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: '面板',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user