Merge christianhaberl's session-sidebar branch, ported to current master

Brings in https://github.com/christianhaberl/Codeman/pull/4 (three commits,
authorship preserved) and adapts it across the 211 commits master gained
since the branch was cut:

- App Settings control re-authored for the set-* surface (PR #278): a
  set-row in Layout -> Tabs, replacing the old settings-item markup the
  branch targeted. i18n description synced.
- Lineage arcs (PR #291, post-branch) are SKIPPED in sidebar layout:
  computeLineagePath()'s U-bridge geometry hangs from the horizontal
  strip's bottom edge and has no meaning against a vertical list. The
  lineage strip-scroll listener now also redraws subagent/ultracode
  connectors while the sidebar scrolls vertically.
- The desktop home tab rail (post-branch) defers to the sidebar: both dock
  the session list flush left, and the rail would render z-ordered under it.
- Active-row reveal unified into _scrollActiveTabIntoView() (#257 landed on
  master after the branch): sidebar mode branches to scrollIntoView
  block:'nearest', and _fullRenderSessionTabs() restores scrollTop alongside
  the #257 scrollLeft restore so ambient rebuilds cannot yank a mid-scroll
  sidebar back to the top.
- Mobile active-tab hoisting the branch guarded against no longer exists on
  master (removed by #257); kept master's order-stable render.

Verified: typecheck, lint, format:check, check:frontend-syntax,
check:public-assets, PostCSS parse of both merged stylesheets, the 26 new
jsdom tests, the structural guard suites, and the headless-Chromium harness
(scripts/verify-session-sidebar.mts) green across all seven layout states
at 1600/1000/393px against current master.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-16 18:28:15 +02:00
20 changed files with 1656 additions and 56 deletions
+381 -12
View File
@@ -428,6 +428,16 @@ const DEFAULT_SHORTCUTS = [
],
action: 'openCommandPalette',
},
{
id: 'toggle-session-sidebar',
group: 'Session',
label: 'Toggle Session Sidebar',
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
// readline backward-char). The Alt block below claims only Digit1-9 and
// the brackets, and the registry claims Alt for KeyK and Slash only.
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
action: 'toggleSessionSidebar',
},
{
id: 'previous-next-session',
group: 'Session',
@@ -873,7 +883,9 @@ class CodemanApp {
this.restorePlanUsageChip();
this.applySkin();
this.applyLocalization();
this.applyTabWrapSettings();
// Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder)
// and then applies the sidebar variant on top — do not call both.
this.applySessionListLayout();
this.applyMonitorVisibility();
this.applyLineageLineSettings?.();
this._installLineageStripScrollListener?.();
@@ -940,7 +952,7 @@ class CodemanApp {
this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyLocalization();
this.applyTabWrapSettings();
this.applySessionListLayout();
this.applyMonitorVisibility();
this.applyLineageLineSettings?.();
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
@@ -1062,6 +1074,7 @@ class CodemanApp {
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
// Use capture to handle before terminal
@@ -1083,6 +1096,14 @@ class CodemanApp {
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeShortcutOverlay?.();
// Overlay layouts only: below 1024px the sidebar is a modal off-canvas
// drawer over the terminal, so Escape must close it. The docked desktop
// sidebar is chrome, not a dialog — collapsing it would be a surprise.
if (this._isSessionSidebarOverlay() &&
this.isSessionSidebarActive() && !this.isSessionSidebarCollapsed()) {
this.toggleSessionSidebar();
document.getElementById('sidebarToggleBtn')?.focus();
}
}
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
@@ -3564,6 +3585,262 @@ class CodemanApp {
}, delayMs);
}
// ═══════════════════════════════════════════════════════════════
// Session List Layout (header strip ⟷ collapsible left sidebar)
// ═══════════════════════════════════════════════════════════════
/**
* '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.
*/
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';
}
/**
* Reads the APPLIED layout off <html>, not the settings blob: this is called
* per dragover event and per tab in render loops, and getSessionListLayout()
* re-parses localStorage on every call. The attribute is written by the
* pre-paint script in index.html and thereafter only by applySessionListLayout(),
* so it is authoritative from the very first frame.
*/
isSessionSidebarActive() {
return document.documentElement.dataset.sessionList === 'sidebar';
}
/**
* True where the sidebar is a MODAL off-canvas drawer over the terminal
* instead of a docked column.
*
* That behaviour is defined purely in mobile.css, which index.html loads with
* media="(max-width: 1023px)" — so this must test the SAME breakpoint.
* MobileDetection.getDeviceType() is NOT usable here: it calls anything
* >= 768px 'desktop', which would leave 768-1023px (iPad portrait, a narrowed
* desktop window) with overlay CSS but docked-sidebar logic — drawer opens
* itself on load, tapping a session doesn't dismiss it, Escape does nothing.
* Mirrored in the pre-paint script in index.html.
*/
_isSessionSidebarOverlay() {
return window.innerWidth < 1024;
}
/**
* Collapse state is per-device and lives in its OWN localStorage key, not in
* the app-settings blob: saveAppSettings() rebuilds that blob from the DOM
* controls, so any key without a control is silently wiped on every Save.
* Precedent: codeman:skin, codeman-session-order, codeman-active-session.
*/
isSessionSidebarCollapsed() {
// In-memory intent wins over storage: where localStorage throws (Safari
// private mode, disabled storage, quota) the write in toggleSessionSidebar()
// is a no-op, and re-reading here would return the OLD value — the sidebar
// would refuse to collapse at all. Persistence degrades, the control does not.
if (this._sidebarCollapsedOverride !== undefined) return this._sidebarCollapsedOverride;
let raw = null;
try {
raw = localStorage.getItem('codeman-sidebar-collapsed');
} catch {}
// Never chosen yet: the docked desktop sidebar starts open, the overlay
// drawer starts CLOSED — "expanded" there would mean a drawer covering the
// terminal on every cold load.
if (raw === null) return this._isSessionSidebarOverlay();
return raw === '1';
}
/**
* True when this keydown is the sidebar-toggle chord AND toggling would
* actually do something. Used by terminal-ui.js's custom key handler to keep
* the chord out of the PTY: the document CAPTURE handler has already toggled
* the sidebar by the time xterm sees the event, but its preventDefault() does
* NOT stop xterm — without this gate Alt+B would ALSO write ESC b into the
* live session, which readline/Ink read as backward-word and which walks the
* cursor back through whatever the user was typing (same trap as COD-153).
*
* Deliberately registry-aware and gated on the sidebar being active, so a
* rebound/disabled shortcut — and the default header layout, where the toggle
* is a no-op — leave Meta-b reaching the terminal exactly as before.
*/
shouldToggleSessionSidebarFromShortcut(e) {
if (!e) return false;
// Every dispatchable binding requires Ctrl/Cmd/Alt, so plain typing exits
// before any registry work — this runs on the xterm keydown hot path.
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
if (!this.isSessionSidebarActive()) return false;
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') {
return false;
}
const shortcut = this.getShortcutRegistry().find((s) => s.id === 'toggle-session-sidebar');
if (!shortcut || shortcut.disabled) return false;
return this.matchesShortcutEvent(e, shortcut);
}
/**
* Move the ONE #sessionTabs element between its two hosts and set the layout
* attributes that all the sidebar CSS keys off.
*
* Never clones or recreates the node: this.$('sessionTabs') caches elements by
* id and never invalidates, and settings-ui.js / webview-tabs.js resolve the
* same id independently. A rebuilt container would leave every consumer
* writing into a detached orphan — silently, with no error.
*/
applySessionListLayout() {
const mode = this.getSessionListLayout();
const collapsed = this.isSessionSidebarCollapsed();
const prevMode = document.documentElement.dataset.sessionList;
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;
if (tabsEl.parentElement !== host) host.appendChild(tabsEl);
document.documentElement.dataset.sessionList = mode;
document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded';
tabsEl.setAttribute('aria-orientation', mode === 'sidebar' ? 'vertical' : 'horizontal');
const btn = document.getElementById('sidebarToggleBtn');
if (btn) {
btn.classList.toggle('btn-sidebar-toggle--hidden', mode !== 'sidebar');
const label = collapsed ? 'Expand session sidebar' : 'Collapse session sidebar';
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
}
// Handheld (mobile.css): the sidebar is an off-canvas overlay, and
// "collapsed" means the drawer is closed.
const aside = document.getElementById('sessionSidebar');
if (aside) {
aside.classList.toggle('open', mode === '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();
aside.toggleAttribute('inert', hiddenDrawer);
if (hiddenDrawer) aside.setAttribute('aria-hidden', 'true');
else aside.removeAttribute('aria-hidden');
}
// 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') {
this._sidebarFilter = '';
const filterInput = document.getElementById('sessionSidebarFilter');
if (filterInput) filterInput.value = '';
}
// applyTabWrapSettings() (settings-ui.js) is the ONE owner of
// tabs-two-rows / tabs-show-folder / _tallTabsEnabled and is itself
// sidebar-aware — it reads the data-session-list attribute set just above,
// so it must run AFTER it. It re-renders by itself when the folder row
// appears or disappears.
const prevTall = this._tallTabsEnabled;
this.applyTabWrapSettings();
// 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) {
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');
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
// here too — not only at the render tails.
this.applySidebarFilter(this._sidebarFilter);
this.updateSidebarCount();
this.updateConnectionLines();
// The desktop home rail defers to the sidebar (both dock the session list
// flush left), so a layout flip while the welcome screen is up has to
// re-evaluate it — showHomeSessions() self-gates on shouldShowHomeSessions().
if (document.getElementById('welcomeOverlay')?.classList.contains('visible')) {
this.showHomeSessions?.();
}
}
toggleSessionSidebar() {
if (!this.isSessionSidebarActive()) return;
const collapsed = !this.isSessionSidebarCollapsed();
this._sidebarCollapsedOverride = collapsed;
try {
localStorage.setItem('codeman-sidebar-collapsed', collapsed ? '1' : '0');
} catch {}
// Collapsing hides the filter row. If focus is sitting in there it would be
// reset to <body>, dropping the user back to the top of the tab order — so
// hand it to the toggle, which is the control they just used.
if (collapsed && this.$('sessionSidebar')?.contains(document.activeElement)) {
document.getElementById('sidebarToggleBtn')?.focus();
}
this.applySessionListLayout();
// Opening the MODAL drawer moves focus into it, as a dialog should. The
// docked desktop sidebar is not modal: stealing focus there would pull the
// caret out of the terminal mid-prompt, and .session-tab handles only
// arrows/Home/End/Enter/Space, so everything typed after would be swallowed.
if (!collapsed && this._isSessionSidebarOverlay()) {
this.$('sessionTabs')?.querySelector('.session-tab.active')?.focus();
}
}
/**
* Overlay layouts only: below 1024px the sidebar is a modal drawer on top of
* the terminal (mobile.css), so picking a session from it must get it out of
* the way again. The docked desktop sidebar stays exactly where the user put
* it. No-op unless the drawer is actually open.
*/
closeSessionSidebarOnHandheld() {
if (!this._isSessionSidebarOverlay()) return;
if (!this.isSessionSidebarActive() || this.isSessionSidebarCollapsed()) return;
this.toggleSessionSidebar();
}
updateSidebarCount() {
const el = document.getElementById('sessionSidebarCount');
if (el) el.textContent = String(this.sessions?.size ?? 0);
}
/**
* Sidebar filter box. Pure DOM class toggling — no re-render, no state on the
* sessions themselves. Matches the rendered aria-label (session name) and the
* title (working directory).
*
* Re-applied at the tail of both render paths: _fullRenderSessionTabs() rebuilds
* innerHTML wholesale, so without that the filtered-out rows flicker back in on
* every SSE tick.
*
* The filter only takes effect while the box that produced it is on screen —
* i.e. the expanded sidebar. In the header strip, the collapsed rail or a
* closed drawer the classes come off, otherwise sessions would stay hidden
* with no visible cause and no reachable control to clear them. The remembered
* needle is restored when the box comes back.
*/
applySidebarFilter(query) {
this._sidebarFilter = (query ?? '').trim().toLowerCase();
const container = this.$('sessionTabs');
if (!container) return;
const reachable =
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const needle = reachable ? this._sidebarFilter : '';
for (const tab of container.querySelectorAll('.session-tab')) {
if (!needle) {
tab.classList.remove('tab-filtered-out');
continue;
}
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
}
}
// ═══════════════════════════════════════════════════════════════
// Session Tabs
// ═══════════════════════════════════════════════════════════════
@@ -3612,6 +3889,16 @@ class CodemanApp {
container.querySelector('.session-tab.active');
if (!tab) return;
// Sidebar layout: the list scrolls VERTICALLY in its own scroller, so the
// horizontal computeTabScrollLeft math below would always no-op (scrollLeft
// pinned at 0). With 25+ sessions the active row is routinely below the
// fold; 'nearest' never scrolls when it is already visible, and only the
// list's own scroller moves — the drawer and document stay put.
if (this.isSessionSidebarActive()) {
tab.scrollIntoView({ block: 'nearest' });
return;
}
const policy = window.CodemanTabOverflow?.computeTabScrollLeft;
if (!policy) return;
const containerRect = container.getBoundingClientRect();
@@ -3636,6 +3923,45 @@ class CodemanApp {
}
}
/**
* Where a floating window (subagent / ultracode) attaches to its parent tab.
* Header strip: below the tab, connector runs vertically. Sidebar: to the
* RIGHT of the tab, connector runs horizontally — otherwise the window spawns
* on top of the sidebar and its bezier loops backwards underneath it.
*/
_tabAnchor(rect) {
if (this.isSessionSidebarActive()) {
return {
x: rect.right,
y: rect.top + rect.height / 2,
spawnLeft: rect.right + 14,
spawnTop: rect.top,
vertical: false,
};
}
return {
x: rect.left + rect.width / 2,
y: rect.bottom,
spawnLeft: rect.left,
spawnTop: rect.bottom,
vertical: true,
};
}
/** Bezier from a _tabAnchor() to a window rect, curving along the right axis. */
_tabConnectorPath(anchor, winRect) {
if (anchor.vertical) {
const x2 = winRect.left + winRect.width / 2;
const y2 = winRect.top;
const midY = (anchor.y + y2) / 2;
return `M ${anchor.x} ${anchor.y} C ${anchor.x} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
}
const x2 = winRect.left;
const y2 = winRect.top + winRect.height / 2;
const midX = (anchor.x + x2) / 2;
return `M ${anchor.x} ${anchor.y} C ${midX} ${anchor.y}, ${midX} ${y2}, ${x2} ${y2}`;
}
_setTerminalLoadState(sessionId, selectGen, phase) {
this.terminalLoadStates.set(sessionId, { generation: selectGen, phase });
this._updateTerminalLoadTab(sessionId);
@@ -3871,6 +4197,9 @@ class CodemanApp {
// it, sliding the lineage arcs off their anchors. Only pay for it when there
// is an arc to keep anchored.
if (this._lineageEdgeCount > 0) this.updateConnectionLines();
this.applySidebarFilter(this._sidebarFilter);
this.updateSidebarCount();
}
// Auto-wrap desktop session tabs to a second row when they overflow one row,
@@ -3880,6 +4209,13 @@ class CodemanApp {
const container = this.$('sessionTabs');
if (!container) return;
// The sidebar list is a single vertical column with its own scroller —
// there is no row to overflow, and measuring it would fight the CSS.
if (this.isSessionSidebarActive()) {
container.classList.remove('tabs-auto-wrap');
return;
}
const deviceType = MobileDetection.getDeviceType();
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -3928,6 +4264,15 @@ class CodemanApp {
if (this._inlineRenameActive) return;
const container = this.$('sessionTabs');
// Sidebar rows are always tall (name + folder) and never wrap. Re-assert it
// here so a render triggered straight from applyTabWrapSettings() — which
// only knows the header strip — cannot leave the sidebar folderless.
if (this.isSessionSidebarActive()) {
this._tallTabsEnabled = true;
container.classList.add('tabs-show-folder');
container.classList.remove('tabs-two-rows', 'tabs-auto-wrap');
}
// Clean up any orphaned dropdowns before re-rendering
document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove());
this.cancelHideSubagentDropdown();
@@ -3938,6 +4283,9 @@ class CodemanApp {
// right-hand tabs kept getting yanked back to the first one. Remember
// where the strip was; the browser clamps the restore to the new content.
const prevScrollLeft = container.scrollLeft;
// Sidebar layout scrolls the same container VERTICALLY, so it needs the
// same protection on the other axis.
const prevScrollTop = container.scrollTop;
const prevActiveTabId = this._lastRenderedActiveTabId;
const isFirstRender = !container.querySelector('.session-tab');
@@ -4025,6 +4373,7 @@ class CodemanApp {
// the strip while a background rebuild fires, without the active tab ever
// being stranded off-screen after a switch.
container.scrollLeft = prevScrollLeft;
container.scrollTop = prevScrollTop;
this._lastRenderedActiveTabId = this.activeSessionId;
if (isFirstRender || prevActiveTabId !== this.activeSessionId) {
this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth');
@@ -4047,6 +4396,11 @@ class CodemanApp {
// Newly created tabs animate in; a re-render mid-cascade resumes them rather
// than restarting, since this rebuild just destroyed the animating elements.
this._applyTabEntrances?.();
// 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);
this.updateSidebarCount();
}
// Set up arrow key navigation for session tabs (accessibility)
@@ -4057,9 +4411,13 @@ class CodemanApp {
}
this._tabKeydownHandler = (e) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
// Up/Down are aliases of Left/Right, not replacements: the strip stays
// arrow-key navigable exactly as before, the vertical sidebar just gains
// the axis a user reaches for there.
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
const tabs = [...container.querySelectorAll('.session-tab')];
// Rows hidden by the sidebar filter must not be steppable.
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
const currentIndex = tabs.indexOf(document.activeElement);
// Enter or Space activates the tab
@@ -4075,9 +4433,11 @@ class CodemanApp {
let newIndex;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
break;
case 'ArrowRight':
case 'ArrowDown':
newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
break;
case 'Home':
@@ -4209,14 +4569,19 @@ class CodemanApp {
e.dataTransfer.dropEffect = 'move';
// Determine drop position based on mouse position
// Determine drop position based on mouse position. Read the layout here,
// inside the handler — these listeners survive a layout flip between
// renders, so capturing the axis at bind time would go stale.
// drag-over-left/-right keep their names and now read as before/after;
// the sidebar CSS just draws them as top/bottom edges.
const rect = tab.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const isLeftHalf = e.clientX < midpoint;
const insertBefore = this.isSessionSidebarActive()
? e.clientY < rect.top + rect.height / 2
: e.clientX < rect.left + rect.width / 2;
// Update visual indicator
tab.classList.toggle('drag-over-left', isLeftHalf);
tab.classList.toggle('drag-over-right', !isLeftHalf);
tab.classList.toggle('drag-over-left', insertBefore);
tab.classList.toggle('drag-over-right', !insertBefore);
});
tab.addEventListener('dragleave', () => {
@@ -4232,10 +4597,11 @@ class CodemanApp {
const targetId = tab.dataset.id;
const draggedId = this.draggedTabId;
// Determine insertion position
// Determine insertion position (same axis rule as the dragover handler)
const rect = tab.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const insertBefore = e.clientX < midpoint;
const insertBefore = this.isSessionSidebarActive()
? e.clientY < rect.top + rect.height / 2
: e.clientX < rect.left + rect.width / 2;
// Reorder sessionOrder array
const fromIndex = this.sessionOrder.indexOf(draggedId);
@@ -4750,6 +5116,9 @@ class CodemanApp {
this.clearPendingHooks(sessionId, 'idle_prompt');
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
this._updateActiveTabImmediate(sessionId);
// Handheld: the session drawer overlays the terminal, so slide it away now
// that a session has been picked. No-op on desktop and in header layout.
this.closeSessionSidebarOnHandheld();
this.renderSessionTabs();
this.updateAttachmentHistoryBadge?.();
if (this.attachmentHistoryDrawerOpen) {