Skip to content

feat(web): optional collapsible left session sidebar - #307

Merged
Ark0N merged 4 commits into
masterfrom
feat/session-sidebar
Aug 16, 2026
Merged

feat(web): optional collapsible left session sidebar#307
Ark0N merged 4 commits into
masterfrom
feat/session-sidebar

Conversation

@Ark0N

Copy link
Copy Markdown
Owner

What this is

An optional, collapsible left session sidebar as an alternative to the header tab strip, contributed by @christianhaberl (built and reviewed on his fork as christianhaberl#4, ported here to current master with his three commits and authorship preserved).

With many concurrent sessions the horizontal strip stops working: it wraps into multiple rows, eats header space, and the hover-grown close button shifts tabs under the cursor. The sidebar puts the session list in a vertical <aside> with a filter box and a live count, collapsible to a 44px rail that keeps status dots and task/subagent/ultracode badges visible. Alt+B toggles it.

Nothing changes unless you opt in. The header strip stays the default; the switch lives in App Settings -> Layout -> Tabs -> Session List Layout (per-device, like the other display keys).

The load-bearing design decision

There is exactly ONE #sessionTabs element. applySessionListLayout() re-parents that same node between the header host and the sidebar. Nothing is cloned or rebuilt, because app.$() caches elements by id and never invalidates: a rebuilt container would leave every consumer (settings-ui, webview-tabs, the gesture bundle, the renderers, drag/keyboard handlers) silently writing into a detached orphan. Consequence: web tabs, Alt+N numbering, alerts, badges, drag-reorder and keyboard navigation all work in both layouts with zero renderer changes.

Below 1024px the sidebar is an off-canvas drawer over the terminal (closed drawers get inert + aria-hidden, selection dismisses it, swipes over it no longer switch sessions). The breakpoint is deliberately 1024, matching mobile.css's media attribute, NOT MobileDetection.getDeviceType(), which calls 768-1023px "desktop" and gave that band overlay CSS with docked logic.

What the port to current master involved

The branch was cut ~211 commits ago, so beyond conflict resolution:

  • App Settings control re-authored for the new set-* surface (One settings surface: App Settings, Session Options and Add Case #278 restructured the modal after the branch was cut): a set-row in Layout -> Tabs replaces the old settings-item markup.
  • Lineage arcs (Skill fast-path hardening, lineage retune + colors, per-tab pop-out, reliable tab alerts #291) are skipped in sidebar layout: computeLineagePath()'s whole geometry hangs a U-bridge from the horizontal strip's bottom edge, which has no meaning against a vertical list. A sideways lineage shape needs its own visual tuning and is a follow-up, not a by-product of re-parenting. The lineage strip-scroll listener now also redraws the subagent/ultracode connectors while the sidebar scrolls vertically (those DO adapt: right-edge anchors, horizontal beziers).
  • The desktop home tab rail defers to the sidebar: both dock the session list flush left, and the rail would render the same list right next to it, z-ordered under it.
  • Active-row reveal unified into _scrollActiveTabIntoView() (Mobile tab bar cannot reach rightmost tabs #257 landed after the branch): sidebar mode branches to scrollIntoView({block: 'nearest'}) because the horizontal computeTabScrollLeft math no-ops against a vertical scroller, and _fullRenderSessionTabs() now restores scrollTop alongside the Mobile tab bar cannot reach rightmost tabs #257scrollLeft restore, so ambient rebuilds (a badge appearing, a session created elsewhere) cannot yank a mid-scroll sidebar back to the top.
  • The mobile active-tab hoisting the branch carefully guarded against was already removed by Mobile tab bar cannot reach rightmost tabs #257; master's order-stable render is kept.

Traps the original PR caught (worth knowing before touching this)

  1. Alt+B also wrote ESC b into the PTY: preventDefault() in the capture handler does not stop xterm, so the chord needs its own gate in attachCustomKeyEventHandler. The gate is registry-aware and only active in sidebar layout, so a rebind or the default layout keeps Meta-b reaching the terminal.
  2. The filter must not outlive its control: it is stripped in the header strip, the collapsed rail and the closed drawer, otherwise sessions stay hidden with no reachable control to clear them.
  3. Collapse state lives in its own codeman-sidebar-collapsed key, not the settings blob, which saveAppSettings() rebuilds from DOM controls (a key without a control is wiped on every Save).
  4. Focus is stolen only by the modal drawer, never the docked sidebar (which would pull the caret out of the terminal mid-prompt).

Verification

  • typecheck, lint, format:check, check:frontend-syntax, check:public-assets all green; both merged stylesheets re-validated with PostCSS.
  • 26 new jsdom tests in test/session-list-layout.test.ts (schema, re-parenting, cache identity, solo windows, collapse round-trip, drawer inert state, the 768-1023 band, filter lifecycle).
  • Structural guards re-run green: app-settings-structure, session-options-structure, mobile-header-buttons-policy, home-sessions, skin-themes, session-lineage-lines.
  • scripts/verify-session-sidebar.mts (the contributor's headless-Chromium harness: a real WebServer, 25 synthetic sessions) re-run against this port: all seven layout states correct at 1600/1000/393px, screenshots eyeballed.
  • Full npm run test:ci sweep green.

Follow-ups (not in this PR)

  • A sideways lineage-arc shape for sidebar layout.
  • The invariants doc entry for the feature is included (docs/architecture-invariants.md, "Session list layout"), with the port decisions documented.

Claudia[bot]and others added 4 commits August 7, 2026 16:42
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>
The script carried two absolute paths from the machine it was written on: a full
scratchpad path including a session UUID, and /home/chaberl/projects as the
synthetic sessions' working directory. This branch is pushed to a public fork, so
they were visible to anyone.
Screenshot output now defaults to tmpdir() and is overridable via
SIDEBAR_SHOTS_DIR; the synthetic working directories are tmpdir()-based too, which
also makes the harness run for anyone who checks the branch out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The synthetic session names end up in the harness screenshots, so shipping one
contributor's project list into everyone else's review reads oddly. The mix of
CLI modes is what the fixture actually needs — each renders a different badge —
and that is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in christianhaberl#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>
@Ark0N
Ark0N merged commit 6946ca0 into masterAug 16, 2026
2 checks passed
CreatureSurvive pushed a commit to CreatureSurvive/Codeman that referenced this pull request Aug 17, 2026
Five post-merge review items from PRs Ark0N#306 (clickable file paths) and
Ark0N#307 (session sidebar):
- constants.js FILE_PREVIEW_EXTENSIONS gains the media extensions it was
missing vs the single-source sets in attachment-registry.ts (m4v ogv
ogg oga m4a aac flac opus), so an in-workspace .m4a opens the preview
player instead of the log viewer; new test/media-extension-parity.test.ts
pins all three copies (constants.js, panels-ui.js, attachment-registry.ts)
against each other.
- FILE_PATH_LINK_PATTERN drops `etc` from its root alternation: /etc is
unconditionally in DEFAULT_BLOCKED_TREES, so every /etc link 403'd.
Negative cases added to the link-provider and response-viewer tests.
- updateSidebarCount() counts the rows actually on the sidebar list
(session rows + web-tab rows, minus filtered-out ones) instead of
this.sessions.size, and applySidebarFilter() refreshes it so the count
follows the filter box per keystroke.
- The incremental-render connection-line gate now also fires in sidebar
layout (this._lineageEdgeCount is permanently 0 there), matching the
strip-scroll listener widened in Ark0N#307, so a badge changing row heights
redraws subagent/ultracode connectors.
- isSensitivePath() blocks ~/.claude.json, ~/.claude/settings.json and
~/.claude/settings.local.json (credential-bearing by schema), anchored
to homedir() read at check time so case-level .claude/settings*.json
files stay servable in the File Viewer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Ark0N@claude