feat(web): optional collapsible left session sidebar

The header tab strip stops working past roughly a dozen sessions: it wraps
into two or three rows, eats vertical space and still cannot be scanned.
This adds a vertical session list in a left <aside> as an ALTERNATIVE
layout — a filter box, a live count, and a 44px collapsed rail that keeps
the ambient signal (status dot, task badge) visible.

The strip is not removed. Settings -> Display -> Tab Bar -> Session List
Layout switches between them and the default stays 'header', so existing
users see no change until they opt in.

Structure: one #sessionTabs element, two mount points. applySessionListLayout()
re-parents the SAME node between #sessionTabsHost and #sessionSidebarList,
which is why there is no second renderer and no duplicated wiring — app.$()
caches getElementById results and never invalidates them, so a moved node
keeps every existing consumer (settings-ui, webview-tabs, the generated
gesture bundle, the mobile tests) working untouched.

Notable integration points:
- Below 1024px the sidebar is an off-canvas drawer overlaying the terminal;
  closed it gets inert + aria-hidden so it cannot be tabbed into, and touch
  swipes over it no longer switch sessions.
- Subagent and ultracode windows anchor to the right edge of a sidebar row
  instead of its bottom, connector curves follow.
- Alt+B toggles; the chord is gated out of the PTY so xterm cannot also
  write ESC b into a live session.
- Collapse state lives in its own localStorage key (the settings blob is
  rebuilt from DOM controls on every save) and falls back to in-memory
  intent where storage throws.

Verified: frontend syntax + public asset checks, tsc, eslint, 26 new jsdom
tests, and a headless-Chromium harness (scripts/verify-session-sidebar.mts)
that renders a synthetic 25-session fleet in both layouts at 1600/1000/393px
and asserts mount point, widths, inert/aria state and row count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudia[bot]
2026-08-07 16:42:21 +02:00
parent d41f28bc14
commit c01edcbbb8
18 changed files with 1609 additions and 57 deletions
+15 -19
View File
@@ -197,10 +197,13 @@ Object.assign(CodemanApp.prototype, {
// Position: spawn from the parent tab if we can find it, else cascade.
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
if (parentTab) {
const r = parentTab.getBoundingClientRect();
const left = Math.max(8, Math.min(r.left, window.innerWidth - 392));
// _tabAnchor() puts the spawn point below the tab in header layout and to
// the RIGHT of it in sidebar layout, so the window never lands on the
// sidebar. The viewport clamp is unchanged.
const anchor = this._tabAnchor(parentTab.getBoundingClientRect());
const left = Math.max(8, Math.min(anchor.spawnLeft, window.innerWidth - 392));
win.style.left = `${left}px`;
win.style.top = `${r.bottom + 14}px`;
win.style.top = `${anchor.spawnTop + (anchor.vertical ? 14 : 0)}px`;
} else {
const n = this.ultracodeWindows.size;
win.style.left = `${24 + n * 26}px`;
@@ -784,16 +787,12 @@ Object.assign(CodemanApp.prototype, {
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
}
// PHASE 2: writes (curve from tab bottom-center to window top-center).
// PHASE 2: writes (curve from the tab anchor to the window — bottom-center to
// top-center in header layout, right-edge to left-edge in sidebar layout).
for (const { runId, parentSessionId, winRect } of winList) {
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) continue;
const x1 = tabRect.left + tabRect.width / 2;
const y1 = tabRect.bottom;
const x2 = winRect.left + winRect.width / 2;
const y2 = winRect.top;
const midY = (y1 + y2) / 2;
const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
const path = this._tabConnectorPath(this._tabAnchor(tabRect), winRect);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line ultracode-connection');
@@ -818,12 +817,13 @@ Object.assign(CodemanApp.prototype, {
if (!info.element) continue;
const winRect = info.element.getBoundingClientRect();
// Anchor: parent run window bottom-center if open, else the run's tab.
let px, py;
// A window anchor is always vertical; a tab anchor follows the session-list
// layout (_tabAnchor), so the curve leaves a sidebar row sideways.
let anchor;
const runWin = info.runId ? this.ultracodeWindows.get(info.runId) : null;
if (runWin && runWin.element) {
const pr = runWin.element.getBoundingClientRect();
px = pr.left + pr.width / 2;
py = pr.bottom;
anchor = { x: pr.left + pr.width / 2, y: pr.bottom, vertical: true };
} else {
const summary = info.runId && this.workflowRuns ? this.workflowRuns.get(info.runId) : null;
const parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
@@ -835,13 +835,9 @@ Object.assign(CodemanApp.prototype, {
}
const tabRect = rects.get(tabKey);
if (!tabRect) continue;
px = tabRect.left + tabRect.width / 2;
py = tabRect.bottom;
anchor = this._tabAnchor(tabRect);
}
const x2 = winRect.left + winRect.width / 2;
const y2 = winRect.top;
const midY = (py + y2) / 2;
const path = `M ${px} ${py} C ${px} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
const path = this._tabConnectorPath(anchor, winRect);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line ultracode-connection ultracode-agent-connection');