40 KiB
Session Sidebar Implementation Plan
Execution: Use the subagent-driven-development workflow to implement this plan.
Goal: Add a collapsible left-side sidebar to the terminal view showing a live session list with snapshot previews, usable as a session switcher without leaving the active terminal.
Architecture: Pure frontend — no backend changes. Sidebar consumes session data already fetched by the poll loop. CSS-only animations (width transition on wide, transform slide on narrow). Responsive at 960px: side-by-side on wide screens, fixed overlay with click-away on narrow.
Tech stack: Vanilla JS, CSS custom properties, Node test runner (node:test), no build step.
Design doc: muxplex/docs/plans/2026-03-27-session-sidebar-design.md
Conventions
- Working directory for git:
/home/bkrabach/dev/web-tmux - Working directory for tests:
/home/bkrabach/dev/web-tmux/muxplex - Test command:
node --test frontend/tests/test_app.mjs - Test framework:
node:test+node:assert/strict(no Jest/Vitest) - Test file:
muxplex/frontend/tests/test_app.mjs— all sidebar tests go here - Import pattern: CJS shim —
const app = require(join(__dirname, '..', 'app.js')) - DOM stubs: injected onto
globalThis.documentbefore require - App state naming:
_camelCasewith leading underscore (_viewingSession,_viewMode) - Helper
$:document.getElementByIdalias — use throughout app.js - Helper
on:element.addEventListeneralias — use for all event binding - Exports: app.js has a
module.exportsblock at lines 887–935 — add all new public functions there - Commits: conventional format, atomic per task, run from
/home/bkrabach/dev/web-tmux
Task 1: HTML structure — sidebar markup, toggle button, view-body wrapper
Files:
- Modify:
muxplex/frontend/index.html
Step 1: Add sidebar toggle button to expanded-header
In muxplex/frontend/index.html, find lines 31–35 (the expanded-header). Insert the sidebar toggle button after #back-btn and before #expanded-session-name:
Replace:
<header class="expanded-header">
<button id="back-btn" class="back-btn" aria-label="Back">←</button>
<span id="expanded-session-name" class="expanded-session-name"></span>
With:
<header class="expanded-header">
<button id="back-btn" class="back-btn" aria-label="Back">←</button>
<button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list">☰</button>
<span id="expanded-session-name" class="expanded-session-name"></span>
Step 2: Wrap terminal-container in view-body and add sidebar
Replace line 36:
<div id="terminal-container" class="terminal-container"></div>
With:
<div class="view-body">
<div id="session-sidebar" class="session-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Sessions</span>
<button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse sidebar">‹</button>
</div>
<div id="sidebar-list" class="sidebar-list"></div>
</div>
<div id="terminal-container" class="terminal-container"></div>
</div>
Important: #reconnect-overlay (line 37) must remain OUTSIDE .view-body — it stays as a sibling of .view-body inside #view-expanded.
Step 3: Verify the final structure
The #view-expanded section should now look like:
<div id="view-expanded" class="view hidden">
<header class="expanded-header">
<button id="back-btn" class="back-btn" aria-label="Back">←</button>
<button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list">☰</button>
<span id="expanded-session-name" class="expanded-session-name"></span>
<button id="palette-trigger" class="palette-trigger" aria-label="Open command palette">⌘K</button>
</header>
<div class="view-body">
<div id="session-sidebar" class="session-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Sessions</span>
<button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse sidebar">‹</button>
</div>
<div id="sidebar-list" class="sidebar-list"></div>
</div>
<div id="terminal-container" class="terminal-container"></div>
</div>
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
</div>
Step 4: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/index.html
git commit -m "feat: add sidebar markup, toggle button, and view-body wrapper to expanded view"
Task 2: CSS — view-body flex layout and --bg-surface variable
Files:
- Modify:
muxplex/frontend/style.css
Step 1: Add --bg-surface CSS variable
In muxplex/frontend/style.css, find the :root block (line 3). Add --bg-surface after line 10 (--bg-tile-hover):
Replace:
--bg-tile-hover: #1A1F2B; /* --color-bg-surface */
With:
--bg-tile-hover: #1A1F2B; /* --color-bg-surface */
--bg-surface: #1A1F2B; /* sidebar hover & active card background */
Step 2: Add view-body layout rules
Find the .terminal-container rule (lines 367–372). Insert .view-body rules immediately BEFORE it:
Insert before .terminal-container {:
/* View body: flex-row region below the expanded header */
.view-body {
display: flex;
flex-direction: row;
flex: 1;
min-height: 0;
overflow: hidden;
}
Step 3: Add flex properties to .terminal-container
The existing .terminal-container rule (lines 367–372) already has flex: 1 and overflow: hidden. Verify it contains:
.terminal-container {
flex: 1;
overflow: hidden;
background: #000;
padding: 0 4px;
}
Add min-width: 0; to prevent flex overflow:
.terminal-container {
flex: 1;
min-width: 0;
overflow: hidden;
background: #000;
padding: 0 4px;
}
Step 4: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/style.css
git commit -m "feat: add view-body flex layout and --bg-surface variable"
Task 3: CSS — sidebar container and collapse animation
Files:
- Modify:
muxplex/frontend/style.css
Step 1: Add sidebar container styles
In muxplex/frontend/style.css, insert the following block immediately after the .view-body rules added in Task 2 (before .terminal-container):
/* ============================================================
Session sidebar
============================================================ */
/* Sidebar container — wide screens (>=960px): side-by-side */
.session-sidebar {
width: 200px;
min-width: 200px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
transition: width 0.25s ease, min-width 0.25s ease;
}
.session-sidebar.sidebar--collapsed {
width: 0;
min-width: 0;
}
/* Sidebar header */
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.sidebar-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.sidebar-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-collapse-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 18px;
padding: 2px 6px;
line-height: 1;
border-radius: 4px;
}
.sidebar-collapse-btn:hover { color: var(--text); }
/* Sidebar toggle in expanded header */
.sidebar-toggle-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
font-size: 14px;
width: 36px;
height: 36px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
flex-shrink: 0;
}
.sidebar-toggle-btn:hover {
border-color: var(--accent);
color: var(--text);
}
Step 2: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/style.css
git commit -m "feat: add sidebar container styles and collapse animation"
Task 4: CSS — sidebar session card styles
Files:
- Modify:
muxplex/frontend/style.css
Step 1: Add session card styles
In muxplex/frontend/style.css, append the following immediately after the sidebar toggle button rules (after .sidebar-toggle-btn:hover), still inside the "Session sidebar" section:
/* Sidebar session cards */
.sidebar-item {
height: 120px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.sidebar-item:hover { background: var(--bg-surface); }
.sidebar-item--active {
background: var(--bg-surface);
border-left: 3px solid var(--accent);
}
.sidebar-item-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px 4px;
flex-shrink: 0;
height: 32px;
gap: 4px;
}
.sidebar-item-name {
font-size: 12px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.sidebar-item-body {
flex: 1;
position: relative;
overflow: hidden;
}
.sidebar-item-body pre {
position: absolute;
bottom: 0;
left: 0;
right: 0;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 10px;
line-height: 1.4;
color: var(--text-muted);
white-space: pre;
overflow: hidden;
margin: 0;
padding: 0 8px 6px;
}
.sidebar-empty {
padding: 16px 12px;
color: var(--text-muted);
font-size: 12px;
text-align: center;
}
Step 2: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/style.css
git commit -m "feat: add sidebar session card styles"
Task 5: CSS — responsive overlay at <960px
Files:
- Modify:
muxplex/frontend/style.css
Step 1: Add responsive media query
In muxplex/frontend/style.css, add the following at the very end of the file (after the .app-wordmark img rule, which is the last rule currently at line 762):
/* ============================================================
Responsive: sidebar becomes overlay at <960px
============================================================ */
@media (max-width: 959px) {
.session-sidebar {
position: fixed;
left: 0;
top: 0;
height: 100%;
z-index: 200;
width: 240px;
min-width: 240px;
transition: transform 0.25s ease;
transform: translateX(0);
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.5);
}
.session-sidebar.sidebar--collapsed {
width: 240px;
min-width: 240px;
transform: translateX(-100%);
}
/* Hide internal chevron in overlay mode — header button is the only toggle */
.sidebar-collapse-btn {
display: none;
}
}
Step 2: Add sidebar to reduced-motion overrides
Find the existing @media (prefers-reduced-motion: reduce) block (around line 726). Add the sidebar transition override inside it:
After .toast { animation: none; } and before the closing }, add:
.session-sidebar {
transition: none;
}
Step 3: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/style.css
git commit -m "feat: add responsive sidebar overlay at <960px and reduced-motion support"
Task 6: buildSidebarHTML — session card HTML builder (TDD)
Files:
- Modify:
muxplex/frontend/app.js - Modify:
muxplex/frontend/tests/test_app.mjs
Step 1: Write the failing tests
In muxplex/frontend/tests/test_app.mjs, add the following test group at the very end of the file (after the last closeBottomSheet test, around line 1648):
// ─── buildSidebarHTML ────────────────────────────────────────────────────
test('buildSidebarHTML marks active session with sidebar-item--active class', () => {
const session = { name: 'work', snapshot: '', bell: null };
const html = app.buildSidebarHTML(session, 'work');
assert.ok(html.includes('sidebar-item--active'), 'active class missing');
});
test('buildSidebarHTML does NOT mark inactive session with sidebar-item--active', () => {
const session = { name: 'work', snapshot: '', bell: null };
const html = app.buildSidebarHTML(session, 'other');
assert.ok(!html.includes('sidebar-item--active'), 'active class should not be present');
});
test('buildSidebarHTML renders bell badge when unseen_count > 0', () => {
const session = { name: 'work', snapshot: '', bell: { unseen_count: 3 } };
const html = app.buildSidebarHTML(session, null);
assert.ok(html.includes('tile-bell'), 'bell badge missing');
assert.ok(html.includes('>3<'), 'bell count not rendered');
});
test('buildSidebarHTML omits bell badge when unseen_count is 0', () => {
const session = { name: 'work', snapshot: '', bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, null);
assert.ok(!html.includes('tile-bell'), 'bell badge should be absent');
});
test('buildSidebarHTML HTML-escapes the session name', () => {
const session = { name: '<script>xss</script>', snapshot: '', bell: null };
const html = app.buildSidebarHTML(session, null);
assert.ok(!html.includes('<script>'), 'XSS not escaped');
assert.ok(html.includes('<script>'), 'expected escaped name');
});
test('buildSidebarHTML returns article element with data-session attribute', () => {
const session = { name: 'my-session', snapshot: 'line1\nline2', bell: null };
const html = app.buildSidebarHTML(session, null);
assert.ok(html.includes('data-session="my-session"'), 'data-session missing');
assert.ok(html.startsWith('<article'), 'should be an article element');
});
test('buildSidebarHTML includes snapshot preview in pre element', () => {
const session = { name: 'x', snapshot: 'hello\nworld', bell: null };
const html = app.buildSidebarHTML(session, null);
assert.ok(html.includes('sidebar-item-body'), 'preview body missing');
assert.ok(html.includes('<pre>'), 'pre element missing');
});
Step 2: Run tests — expect FAIL
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | tail -15
Expected: FAIL — app.buildSidebarHTML is not a function (or similar TypeError).
Step 3: Implement buildSidebarHTML
In muxplex/frontend/app.js, add the following function immediately after the buildTileHTML function (after line 301, before the renderGrid function):
/**
* Build HTML for a single sidebar session card.
* @param {object} session
* @param {string|null} currentSession - name of the currently open session
* @returns {string}
*/
function buildSidebarHTML(session, currentSession) {
const name = session.name || '';
const escapedName = escapeHtml(name);
const isActive = name === currentSession;
const classes = 'sidebar-item' + (isActive ? ' sidebar-item--active' : '');
const unseen = session.bell && session.bell.unseen_count;
let bellHtml = '';
if (unseen && unseen > 0) {
const countStr = unseen > 9 ? '9+' : String(unseen);
bellHtml = `<span class="tile-bell">${countStr}</span>`;
}
const snapshot = session.snapshot || '';
const lastLines = snapshot.split('\n').slice(-20).join('\n');
return (
`<article class="${classes}" data-session="${escapedName}" tabindex="0" role="listitem" aria-label="${escapedName}">` +
`<div class="sidebar-item-header">` +
`<span class="sidebar-item-name">${escapeHtml(name)}</span>` +
`${bellHtml}` +
`</div>` +
`<div class="sidebar-item-body"><pre>${escapeHtml(lastLines)}</pre></div>` +
`</article>`
);
}
Step 4: Export buildSidebarHTML
In the module.exports block at the bottom of muxplex/frontend/app.js (around line 900), add buildSidebarHTML after the buildTileHTML line:
Find:
buildTileHTML,
renderGrid,
Replace with:
buildTileHTML,
buildSidebarHTML,
renderGrid,
Step 5: Run tests — expect PASS
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: all tests pass (existing + 7 new sidebar tests), 0 failures.
Step 6: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs
git commit -m "feat: add buildSidebarHTML — session card HTML builder for sidebar"
Task 7: renderSidebar — populate sidebar list from session data (TDD)
Files:
- Modify:
muxplex/frontend/app.js - Modify:
muxplex/frontend/tests/test_app.mjs
Step 1: Write the failing tests
In muxplex/frontend/tests/test_app.mjs, add after the buildSidebarHTML tests:
// ─── renderSidebar ───────────────────────────────────────────────────────
test('renderSidebar populates sidebar-list when view is fullscreen', () => {
let capturedHtml = '';
const fakeList = {
querySelectorAll: () => [],
get innerHTML() { return capturedHtml; },
set innerHTML(v) { capturedHtml = v; },
};
const origGetEl = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => id === 'sidebar-list' ? fakeList : null;
app._setViewMode('fullscreen');
const sessions = [
{ name: 'work', snapshot: 'line', bell: null },
{ name: 'play', snapshot: '', bell: { unseen_count: 1 } },
];
app.renderSidebar(sessions, 'work');
assert.ok(capturedHtml.includes('sidebar-item'), 'no sidebar items rendered');
assert.ok(capturedHtml.includes('sidebar-item--active'), 'active session not marked');
globalThis.document.getElementById = origGetEl;
app._setViewMode('grid');
});
test('renderSidebar renders empty message when sessions array is empty', () => {
let capturedHtml = '';
const fakeList = {
querySelectorAll: () => [],
get innerHTML() { return capturedHtml; },
set innerHTML(v) { capturedHtml = v; },
};
const origGetEl = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => id === 'sidebar-list' ? fakeList : null;
app._setViewMode('fullscreen');
app.renderSidebar([], null);
assert.ok(capturedHtml.includes('sidebar-empty'), 'empty state message missing');
assert.ok(capturedHtml.includes('No sessions'), 'empty text missing');
globalThis.document.getElementById = origGetEl;
app._setViewMode('grid');
});
test('renderSidebar does nothing when view is not fullscreen', () => {
let rendered = false;
const fakeList = {
set innerHTML(v) { rendered = true; },
querySelectorAll: () => [],
};
const origGetEl = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => id === 'sidebar-list' ? fakeList : null;
app._setViewMode('grid');
app.renderSidebar([{ name: 'x', snapshot: '', bell: null }], null);
assert.ok(!rendered, 'renderSidebar should not render in grid mode');
globalThis.document.getElementById = origGetEl;
});
Step 2: Run tests — expect FAIL
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | grep -E "FAIL|renderSidebar" | head -10
Expected: FAIL — app.renderSidebar is not a function.
Step 3: Implement renderSidebar
In muxplex/frontend/app.js, add the following function immediately after buildSidebarHTML (before renderGrid):
/**
* Render the sidebar session list. No-ops when dashboard is active.
* @param {object[]} sessions
* @param {string|null} currentSession
*/
function renderSidebar(sessions, currentSession) {
if (_viewMode !== 'fullscreen') return;
const list = $('sidebar-list');
if (!list) return;
if (!sessions || sessions.length === 0) {
list.innerHTML = '<div class="sidebar-empty">No sessions</div>';
return;
}
list.innerHTML = sessions.map((s) => buildSidebarHTML(s, currentSession)).join('');
list.querySelectorAll('.sidebar-item').forEach((item) => {
on(item, 'click', () => {
const name = item.dataset.session;
if (name && name !== currentSession) openSession(name);
});
});
}
Step 4: Export renderSidebar
In the module.exports block, add renderSidebar after buildSidebarHTML:
Find:
buildSidebarHTML,
renderGrid,
Replace with:
buildSidebarHTML,
renderSidebar,
renderGrid,
Step 5: Run tests — expect PASS
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: all tests pass, 0 failures.
Step 6: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs
git commit -m "feat: add renderSidebar — populates sidebar list from session data"
Task 8: initSidebar + toggleSidebar with localStorage persistence (TDD)
Files:
- Modify:
muxplex/frontend/app.js - Modify:
muxplex/frontend/tests/test_app.mjs
Step 1: Add localStorage and window.innerWidth stubs to test file
In muxplex/frontend/tests/test_app.mjs, add the following BEFORE the import statements (after the existing globalThis.Notification stub around line 25, before the navigator defineProperty):
const _localStorageStore = {};
globalThis.localStorage = {
getItem: (k) => _localStorageStore[k] ?? null,
setItem: (k, v) => { _localStorageStore[k] = String(v); },
removeItem: (k) => { delete _localStorageStore[k]; },
};
Also add innerWidth to the existing globalThis.window object. Find:
globalThis.window = {
addEventListener: () => {},
location: { href: '' },
};
Replace with:
globalThis.window = {
addEventListener: () => {},
location: { href: '' },
innerWidth: 1024,
};
Step 2: Write the failing tests
Add at the end of muxplex/frontend/tests/test_app.mjs:
// ─── initSidebar + toggleSidebar ──────────────────────────────────────────
test('initSidebar defaults to open on wide screens when no stored value', () => {
delete _localStorageStore['muxplex.sidebarOpen'];
let removedClass = null;
const fakeSidebar = {
classList: {
add: () => {},
remove: (c) => { removedClass = c; },
},
};
const origGetEl = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => id === 'session-sidebar' ? fakeSidebar : null;
globalThis.window.innerWidth = 1200;
app.initSidebar();
assert.strictEqual(removedClass, 'sidebar--collapsed', 'should remove collapsed class on wide screen');
globalThis.document.getElementById = origGetEl;
});
test('initSidebar defaults to closed on narrow screens when no stored value', () => {
delete _localStorageStore['muxplex.sidebarOpen'];
let addedClass = null;
const fakeSidebar = {
classList: {
add: (c) => { addedClass = c; },
remove: () => {},
},
};
const origGetEl = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => id === 'session-sidebar' ? fakeSidebar : null;
globalThis.window.innerWidth = 600;
app.initSidebar();
assert.strictEqual(addedClass, 'sidebar--collapsed', 'should add collapsed class on narrow screen');
globalThis.document.getElementById = origGetEl;
});
test('initSidebar respects stored value true regardless of screen width', () => {
_localStorageStore['muxplex.sidebarOpen'] = 'true';
let removedClass = null;
const fakeSidebar = {
classList: {
add: () => {},
remove: (c) => { removedClass = c; },
},
};
const origGetEl = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => id === 'session-sidebar' ? fakeSidebar : null;
globalThis.window.innerWidth = 600; // narrow, but stored says open
app.initSidebar();
assert.strictEqual(removedClass, 'sidebar--collapsed', 'should respect stored true value');
globalThis.document.getElementById = origGetEl;
});
test('toggleSidebar persists state to localStorage', () => {
_localStorageStore['muxplex.sidebarOpen'] = 'true';
const fakeSidebar = { classList: { add: () => {}, remove: () => {} } };
const fakeBtn = { textContent: '' };
const origGetEl = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => {
if (id === 'session-sidebar') return fakeSidebar;
if (id === 'sidebar-collapse-btn') return fakeBtn;
return null;
};
app.toggleSidebar();
assert.strictEqual(_localStorageStore['muxplex.sidebarOpen'], 'false', 'should toggle to false');
globalThis.document.getElementById = origGetEl;
});
test('toggleSidebar adds sidebar--collapsed class when closing', () => {
_localStorageStore['muxplex.sidebarOpen'] = 'true';
let addedClass = null;
const fakeSidebar = {
classList: {
add: (c) => { addedClass = c; },
remove: () => {},
},
};
const fakeBtn = { textContent: '' };
const origGetEl = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => {
if (id === 'session-sidebar') return fakeSidebar;
if (id === 'sidebar-collapse-btn') return fakeBtn;
return null;
};
app.toggleSidebar();
assert.strictEqual(addedClass, 'sidebar--collapsed');
globalThis.document.getElementById = origGetEl;
});
test('toggleSidebar removes sidebar--collapsed class when opening', () => {
_localStorageStore['muxplex.sidebarOpen'] = 'false';
let removedClass = null;
const fakeSidebar = {
classList: {
add: () => {},
remove: (c) => { removedClass = c; },
},
};
const fakeBtn = { textContent: '' };
const origGetEl = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => {
if (id === 'session-sidebar') return fakeSidebar;
if (id === 'sidebar-collapse-btn') return fakeBtn;
return null;
};
app.toggleSidebar();
assert.strictEqual(removedClass, 'sidebar--collapsed');
assert.strictEqual(_localStorageStore['muxplex.sidebarOpen'], 'true', 'should toggle to true');
globalThis.document.getElementById = origGetEl;
});
Step 3: Run tests — expect FAIL
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | grep -E "FAIL|initSidebar|toggleSidebar" | head -10
Expected: FAIL — app.initSidebar is not a function.
Step 4: Implement initSidebar and toggleSidebar
In muxplex/frontend/app.js, add the following two functions after renderSidebar (before renderGrid):
/**
* Initialise sidebar open/closed state from localStorage.
* Defaults: open on wide screens (>=960px), closed on narrow.
*/
function initSidebar() {
const isNarrow = window.innerWidth < 960;
let open;
try { open = JSON.parse(localStorage.getItem('muxplex.sidebarOpen')); } catch { open = null; }
if (open === null) open = !isNarrow;
const sidebar = $('session-sidebar');
if (sidebar) {
if (open) {
sidebar.classList.remove('sidebar--collapsed');
} else {
sidebar.classList.add('sidebar--collapsed');
}
}
try { localStorage.setItem('muxplex.sidebarOpen', JSON.stringify(open)); } catch {}
}
/**
* Toggle sidebar open/closed; persists new state to localStorage.
*/
function toggleSidebar() {
let open;
try { open = JSON.parse(localStorage.getItem('muxplex.sidebarOpen')); } catch { open = null; }
const newOpen = open === null ? false : !open;
try { localStorage.setItem('muxplex.sidebarOpen', JSON.stringify(newOpen)); } catch {}
const sidebar = $('session-sidebar');
if (sidebar) {
if (newOpen) {
sidebar.classList.remove('sidebar--collapsed');
} else {
sidebar.classList.add('sidebar--collapsed');
}
}
const collapseBtn = $('sidebar-collapse-btn');
if (collapseBtn) collapseBtn.textContent = newOpen ? '\u2039' : '\u203a';
}
Step 5: Export both functions
In the module.exports block, add initSidebar and toggleSidebar. Find:
renderSidebar,
renderGrid,
Replace with:
renderSidebar,
initSidebar,
toggleSidebar,
renderGrid,
Step 6: Run tests — expect PASS
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: all tests pass, 0 failures.
Step 7: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs
git commit -m "feat: add initSidebar and toggleSidebar with localStorage persistence"
Task 9: Wire toggle button click handlers
Files:
- Modify:
muxplex/frontend/app.js - Modify:
muxplex/frontend/tests/test_app.mjs
Step 1: Write the failing test
Add at the end of muxplex/frontend/tests/test_app.mjs:
// ─── Sidebar toggle wiring ───────────────────────────────────────────────
test('bindStaticEventListeners binds sidebar-toggle-btn click', () => {
const eventsBound = {};
const origGetById = globalThis.document.getElementById;
const origDocAddListener = globalThis.document.addEventListener;
globalThis.document.getElementById = (id) => {
const el = { _events: {}, addEventListener: (ev, fn) => { el._events[ev] = fn; } };
eventsBound[id] = el;
return el;
};
globalThis.document.addEventListener = () => {};
app.bindStaticEventListeners();
assert.ok(
eventsBound['sidebar-toggle-btn'] && 'click' in eventsBound['sidebar-toggle-btn']._events,
'#sidebar-toggle-btn should have a click listener',
);
assert.ok(
eventsBound['sidebar-collapse-btn'] && 'click' in eventsBound['sidebar-collapse-btn']._events,
'#sidebar-collapse-btn should have a click listener',
);
globalThis.document.getElementById = origGetById;
globalThis.document.addEventListener = origDocAddListener;
});
Step 2: Run tests — expect FAIL
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | grep "sidebar-toggle-btn" | head -5
Expected: FAIL — sidebar-toggle-btn does not have a click listener.
Step 3: Add toggle bindings to bindStaticEventListeners
In muxplex/frontend/app.js, find the bindStaticEventListeners function (around line 817). Add the two sidebar toggle bindings. Find:
function bindStaticEventListeners() {
on($('back-btn'), 'click', closeSession);
on($('palette-trigger'), 'click', openPalette);
Replace with:
function bindStaticEventListeners() {
on($('back-btn'), 'click', closeSession);
on($('sidebar-toggle-btn'), 'click', toggleSidebar);
on($('sidebar-collapse-btn'), 'click', toggleSidebar);
on($('palette-trigger'), 'click', openPalette);
Step 4: Run tests — expect PASS
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: all tests pass, 0 failures.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs
git commit -m "feat: wire sidebar toggle button click handlers"
Task 10: Pre-render sidebar on openSession
Files:
- Modify:
muxplex/frontend/app.js - Modify:
muxplex/frontend/tests/test_app.mjs
Step 1: Write the failing test
Add at the end of muxplex/frontend/tests/test_app.mjs:
// ─── Sidebar integration with openSession ────────────────────────────────
test('initSidebar and renderSidebar are exported for openSession integration', () => {
assert.strictEqual(typeof app.initSidebar, 'function', 'initSidebar must be exported');
assert.strictEqual(typeof app.renderSidebar, 'function', 'renderSidebar must be exported');
});
Step 2: Run tests — expect PASS (this is a guard test)
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: PASS — both functions already exported from earlier tasks.
Step 3: Add sidebar pre-render to openSession
In muxplex/frontend/app.js, find the openSession function (around line 459). Add sidebar initialization right after the _viewMode assignment. Find:
async function openSession(name, opts = {}) {
_viewingSession = name;
_viewMode = 'fullscreen';
// Update expanded header
Replace with:
async function openSession(name, opts = {}) {
_viewingSession = name;
_viewMode = 'fullscreen';
// Pre-render sidebar with current sessions before first poll tick
initSidebar();
renderSidebar(_currentSessions, name);
// Update expanded header
Step 4: Add sidebar render after view becomes visible
In the same openSession function, find the setTimeout callback (around line 487). Add sidebar render at the end of the callback body, AFTER the _openTerminal call. Find:
// Mount terminal AFTER view is visible so FitAddon measures real dimensions
if (window._openTerminal) window._openTerminal(name);
}, 260);
Replace with:
// Mount terminal AFTER view is visible so FitAddon measures real dimensions
if (window._openTerminal) window._openTerminal(name);
// Re-render sidebar after DOM is visible and dimensions are correct
initSidebar();
renderSidebar(_currentSessions, name);
}, 260);
Step 5: Run all tests
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: all tests pass, 0 failures. The existing openSession tests should continue to pass because they mock setTimeout and document.getElementById to return stubs.
Step 6: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs
git commit -m "feat: pre-render sidebar on openSession before first poll tick"
Task 11: Wire renderSidebar into the poll loop
Files:
- Modify:
muxplex/frontend/app.js - Modify:
muxplex/frontend/tests/test_app.mjs
Step 1: Write the failing test
Add at the end of muxplex/frontend/tests/test_app.mjs:
// ─── Sidebar in poll loop ────────────────────────────────────────────────
test('pollSessions calls renderSidebar (verified via sidebar-list innerHTML)', async () => {
let sidebarRendered = false;
const origGetById = globalThis.document.getElementById;
const origQSA = globalThis.document.querySelectorAll;
globalThis.document.querySelectorAll = () => [];
globalThis.document.getElementById = (id) => {
if (id === 'sidebar-list') {
return {
querySelectorAll: () => [],
set innerHTML(v) { sidebarRendered = true; },
get innerHTML() { return ''; },
};
}
if (id === 'session-grid') return { innerHTML: '' };
if (id === 'empty-state') return { classList: { add: () => {}, remove: () => {} } };
if (id === 'connection-status') return { textContent: '', className: '' };
if (id === 'session-pill-bell') return { classList: { add: () => {}, remove: () => {} } };
return null;
};
const sessions = [{ name: 'test', snapshot: 'hello', bell: null }];
globalThis.fetch = async () => ({ ok: true, json: async () => sessions });
app._setViewMode('fullscreen');
await app.pollSessions();
assert.ok(sidebarRendered, 'renderSidebar should be called during pollSessions in fullscreen mode');
globalThis.document.getElementById = origGetById;
globalThis.document.querySelectorAll = origQSA;
globalThis.fetch = undefined;
app._setViewMode('grid');
});
Step 2: Run tests — expect FAIL
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | grep "pollSessions calls renderSidebar"
Expected: FAIL — sidebarRendered is still false because pollSessions doesn't call renderSidebar yet.
Step 3: Add renderSidebar call to pollSessions
In muxplex/frontend/app.js, find the pollSessions function. Add renderSidebar after renderGrid. Find:
setConnectionStatus('ok');
renderGrid(sessions);
handleBellTransitions(prev, sessions);
Replace with:
setConnectionStatus('ok');
renderGrid(sessions);
renderSidebar(sessions, _viewingSession);
handleBellTransitions(prev, sessions);
Step 4: Run tests — expect PASS
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: all tests pass, 0 failures.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs
git commit -m "feat: wire renderSidebar into poll loop for live sidebar updates"
Task 12: Click-away handler for overlay mode
Files:
- Modify:
muxplex/frontend/app.js - Modify:
muxplex/frontend/tests/test_app.mjs
Step 1: Write the failing test
Add at the end of muxplex/frontend/tests/test_app.mjs:
// ─── bindSidebarClickAway ────────────────────────────────────────────────
test('bindSidebarClickAway is exported and callable', () => {
assert.strictEqual(typeof app.bindSidebarClickAway, 'function');
});
test('bindSidebarClickAway registers click listener on terminal-container', () => {
let clickBound = false;
const fakeTC = {
addEventListener: (ev) => { if (ev === 'click') clickBound = true; },
};
const origGetEl = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => id === 'terminal-container' ? fakeTC : null;
app.bindSidebarClickAway();
assert.ok(clickBound, 'should register click on terminal-container');
globalThis.document.getElementById = origGetEl;
});
Step 2: Run tests — expect FAIL
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | grep "bindSidebarClickAway" | head -5
Expected: FAIL — app.bindSidebarClickAway is not a function.
Step 3: Implement bindSidebarClickAway
In muxplex/frontend/app.js, add the following function after toggleSidebar (before renderGrid):
/**
* In overlay mode (<960px), clicking the terminal area closes the sidebar.
*/
function bindSidebarClickAway() {
const tc = $('terminal-container');
if (!tc) return;
on(tc, 'click', () => {
if (window.innerWidth >= 960) return;
const sidebar = $('session-sidebar');
if (!sidebar || sidebar.classList.contains('sidebar--collapsed')) return;
sidebar.classList.add('sidebar--collapsed');
try { localStorage.setItem('muxplex.sidebarOpen', JSON.stringify(false)); } catch {}
});
}
Step 4: Export and wire bindSidebarClickAway
Add to module.exports:
Find:
toggleSidebar,
renderGrid,
Replace with:
toggleSidebar,
bindSidebarClickAway,
renderGrid,
Add the call to bindStaticEventListeners. Find:
on($('sidebar-collapse-btn'), 'click', toggleSidebar);
on($('palette-trigger'), 'click', openPalette);
Replace with:
on($('sidebar-collapse-btn'), 'click', toggleSidebar);
bindSidebarClickAway();
on($('palette-trigger'), 'click', openPalette);
Step 5: Run ALL tests — final verification
cd /home/bkrabach/dev/web-tmux/muxplex
node --test frontend/tests/test_app.mjs 2>&1 | tail -8
Expected: all tests pass, 0 failures.
Also verify no backend tests are broken:
cd /home/bkrabach/dev/web-tmux/muxplex
python -m pytest coordinator/tests/ --ignore=coordinator/tests/test_integration.py -q 2>&1 | tail -3
Expected: all pass, 0 failures.
Step 6: Commit
cd /home/bkrabach/dev/web-tmux
git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs
git commit -m "feat: add overlay click-away handler for sidebar on narrow screens
Sidebar now live-updates on every poll tick alongside the dashboard.
Clicking the terminal area in overlay mode (<960px) auto-closes sidebar."
Summary of changes by file
| File | Tasks | Lines added (approx) |
|---|---|---|
muxplex/frontend/index.html |
1 | ~12 |
muxplex/frontend/style.css |
2, 3, 4, 5 | ~180 |
muxplex/frontend/app.js |
6, 7, 8, 9, 10, 11, 12 | ~110 |
muxplex/frontend/tests/test_app.mjs |
6, 7, 8, 9, 10, 11, 12 | ~210 |
Total commits: 12 (one per task) Backend changes: None New files: None New dependencies: None