diff --git a/docs/plans/2026-03-27-session-sidebar-design.md b/docs/plans/2026-03-27-session-sidebar-design.md new file mode 100644 index 0000000..7a47214 --- /dev/null +++ b/docs/plans/2026-03-27-session-sidebar-design.md @@ -0,0 +1,139 @@ +# Session Sidebar Design + +## Goal + +Add a collapsible left-side sidebar to the terminal (expanded) view that shows a live-updating session list with snapshot previews, usable as a session switcher while actively working in a terminal session. + +## Background + +The current workflow requires returning to the dashboard to switch sessions or see what's happening in other terminals. A persistent sidebar in the terminal view eliminates that round-trip, giving users at-a-glance visibility into all sessions while keeping the active terminal front and center. + +## Approach + +Pure frontend addition — no backend or coordinator changes. The sidebar consumes the same `sessions` data already fetched by the poll loop, reuses the existing `openSession()` function for switching, and relies entirely on CSS for layout transitions and responsive behaviour. New code is limited to a `renderSidebar()` function and associated styles. + +## Architecture + +### Layout + +`view-expanded` becomes a flex row with two children: + +``` +┌──────────────────────────────────────────────────┐ +│ view-expanded (display: flex; flex-direction: row) │ +│ ┌────────────┐ ┌──────────────────────────────┐ │ +│ │ .session- │ │ .terminal-container │ │ +│ │ sidebar │ │ (flex: 1) │ │ +│ │ (200px) │ │ │ │ +│ │ │ │ │ │ +│ │ .sidebar- │ │ │ │ +│ │ header │ │ │ │ +│ │ .sidebar- │ │ │ │ +│ │ list │ │ │ │ +│ └────────────┘ └──────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +### Data Flow + +``` +poll tick + ├── renderGrid(sessions) ← dashboard (unchanged) + └── renderSidebar(sessions, name) ← sidebar (new, same data) +``` + +No extra network requests. `renderSidebar` skips work when the dashboard view is active. + +## Components + +### 1 — Layout and Structure + +- `view-expanded` container: `display: flex; flex-direction: row`. +- `.session-sidebar`: fixed `width: 200px`, positioned left. + - `.sidebar-header`: title text + toggle button. + - `.sidebar-list`: `overflow-y: auto`, scrollable card list. +- `.terminal-container`: `flex: 1`, fills remaining width. +- Collapse: toggling `.sidebar--collapsed` sets `width: 0; overflow: hidden` with a CSS transition (`0.25s ease`). +- Toggle button: sidebar icon (`⋮⋮` or hamburger) in the expanded view header bar next to the existing back button. Secondary `‹`/`›` chevron at the sidebar's right edge in wide (side-by-side) mode only. +- State persisted in `localStorage('muxplex.sidebarOpen')`. + +### 2 — Session Cards + +Each entry in `.sidebar-list` is a compact card: + +``` +┌─────────────────────────────────┐ +│ ● session-name [bell 2] │ ← header row: name + badge +├─────────────────────────────────┤ +│ last line of output │ +│ $ some command running... │ ← snapshot preview, bottom-anchored +│ (blank) │ +└─────────────────────────────────┘ +``` + +- Card height: ~120px (header ~32px + 4–6 lines of 11px monospace preview). +- Snapshot preview: same `lastLines` data already on each session, same `position: absolute; bottom: 0` bottom-anchoring CSS already shipped, restyled for narrower column. +- Active session card: 3px left border in `var(--accent-cyan)`, slightly elevated background (`var(--bg-surface)`). +- Click handler: calls existing `openSession(name)`. Clicking the active session's card is a no-op. +- Session order: mirrors coordinator response order (bell-priority on mobile, natural order on desktop) — no new sorting logic. + +### 3 — Data Flow and State + +Two pieces of state: + +| Variable | Type | Source | Purpose | +|---|---|---|---| +| `currentSession` | `string \| null` | Set by `openSession(name)`, cleared by `closeSession()` | Determines which card gets active highlight | +| `sidebarOpen` | `boolean` | `localStorage('muxplex.sidebarOpen')` | Controls `.sidebar--collapsed` CSS class | + +- `renderSidebar(sessions, currentSession)` is called alongside the existing `renderGrid(sessions)` on every poll tick — same data, zero extra network requests. +- `renderSidebar` only does visible work when the terminal view is active. If the dashboard is showing, the sidebar DOM exists but rendering is skipped. +- On `openSession`, the sidebar gets an initial render before the first poll tick catches up. +- `sidebarOpen` toggle applies/removes `.sidebar--collapsed` — animation is CSS-only, no JS timers. Value written back to localStorage on every toggle. + +### 4 — Responsive / Overlay Behaviour + +**Breakpoint: 960px** viewport width. + +| Mode | Width | Sidebar Position | Animation | Chevron | +|---|---|---|---|---| +| Side-by-side | ≥960px | In-flow, flex child | `width: 0/200px` | `‹`/`›` at right edge | +| Overlay | <960px | `position: fixed; left: 0; top: 0; height: 100%` | `transform: translateX(-100%/0)` 0.25s ease | None | + +- In overlay mode the terminal stays full-width — nothing shrinks. +- Clicking anywhere on `.terminal-container` closes the overlay. +- Single toggle button always lives in the expanded header bar regardless of mode. +- Default `sidebarOpen`: `true` on wide screens, `false` on narrow (when no localStorage value exists). Stored value respected on subsequent visits regardless of current screen width. + +## Error Handling + +- Empty sessions array: `renderSidebar` produces an empty list — no crash, no error state. +- Session names are HTML-escaped before rendering to prevent injection. +- `localStorage` read/write wrapped defensively — if unavailable, defaults apply and toggle still works for the current page session. + +## Testing Strategy + +Unit tests in `frontend/tests/test_app.mjs`, three groups: + +### `buildSidebarHTML` output +- Active session card gets `.sidebar-item--active` class; non-active cards do not. +- Bell badge renders when `unseen_count > 0`; absent when 0 or null. +- Session name is HTML-escaped (injection check). +- Empty sessions array produces an empty list, no crash. +- Snapshot preview uses bottom-anchored last-lines approach. + +### Sidebar state +- `sidebarOpen` written to `localStorage` on every toggle. +- Default value: `true` when no stored value exists and viewport is wide; `false` when narrow. +- `.sidebar--collapsed` class added/removed in sync with stored value. + +### Session switching +- Sidebar card click calls `openSession(name)` with the correct session name. +- Clicking the active session's card is a no-op. + +### Not unit-tested +CSS breakpoint behaviour and overlay slide animation are verified by visual inspection during implementation — not unit-testable. + +## Open Questions + +None — all sections validated.