From 70b8df145868273ab501c267bb37b5c0b98dac07 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 31 Mar 2026 01:16:21 -0700 Subject: [PATCH] feat: sidebar device grouping when multiple sources configured (task-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace renderSidebar to group sessions by deviceName when _sources.length > 1 - Render sidebar-device-header h4 elements before each device's sessions - When single source configured, render flat list with no headers - Click handlers now pass sourceUrl to openSession - Add tests: groups with header when multiple sources, flat when single source 🤖 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- muxplex/frontend/app.js | 27 +++++++++-- muxplex/frontend/tests/test_app.mjs | 71 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 3892347..a8310e7 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -561,14 +561,35 @@ function renderSidebar(sessions, currentSession) { return; } - list.innerHTML = visible.map((session) => buildSidebarHTML(session, currentSession)).join(''); + let html = ''; - // Bind click handlers on each sidebar item + if (_sources.length > 1) { + // Group sessions by deviceName when multiple sources configured + const groups = new Map(); + for (const session of visible) { + const deviceName = session.deviceName || 'Unknown'; + if (!groups.has(deviceName)) groups.set(deviceName, []); + groups.get(deviceName).push(session); + } + + for (const [deviceName, deviceSessions] of groups) { + html += ``; + html += deviceSessions.map((session) => buildSidebarHTML(session, currentSession)).join(''); + } + } else { + // Single source: flat list with no device headers + html = visible.map((session) => buildSidebarHTML(session, currentSession)).join(''); + } + + list.innerHTML = html; + + // Bind click handlers on each sidebar item, passing sourceUrl if (typeof list.querySelectorAll === 'function') { list.querySelectorAll('.sidebar-item').forEach((item) => { const name = item.dataset.session; + const sourceUrl = item.dataset.sourceUrl || ''; on(item, 'click', () => { - if (name !== currentSession) openSession(name); + if (name !== currentSession) openSession(name, { sourceUrl }); }); }); } diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 9c19d8f..9998e01 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2666,4 +2666,75 @@ test('saveGridViewMode stores to localStorage when scope is local', () => { app._setGridViewMode('flat'); }); +// --- renderSidebar device grouping (task-16) --- + +test('renderSidebar groups sessions by device with sidebar-device-header when multiple sources configured', () => { + let capturedHTML = ''; + const mockList = { + get innerHTML() { return capturedHTML; }, + set innerHTML(v) { capturedHTML = v; }, + querySelectorAll: () => [], + }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'sidebar-list') return mockList; + return null; + }; + + // Set up multiple sources + app._setSources([ + { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, + { url: 'https://remote.example.com', name: 'Server', type: 'remote', status: 'authenticated', backoffMs: 2000 }, + ]); + + app._setViewMode('fullscreen'); + const sessions = [ + { name: 'alpha', deviceName: 'Laptop', sourceUrl: '', sessionKey: '::alpha', snapshot: '', bell: { unseen_count: 0 } }, + { name: 'beta', deviceName: 'Server', sourceUrl: 'https://remote.example.com', sessionKey: 'https://remote.example.com::beta', snapshot: '', bell: { unseen_count: 0 } }, + ]; + app.renderSidebar(sessions, null); + + assert.ok(capturedHTML.includes('sidebar-device-header'), 'sidebar HTML should contain sidebar-device-header elements when multiple sources'); + assert.ok(capturedHTML.includes('Laptop'), 'sidebar HTML should contain device name "Laptop"'); + assert.ok(capturedHTML.includes('Server'), 'sidebar HTML should contain device name "Server"'); + + // Cleanup + app._setSources([]); + globalThis.document.getElementById = origGetById; + app._setViewMode('grid'); +}); + +test('renderSidebar does NOT group when only one source configured', () => { + let capturedHTML = ''; + const mockList = { + get innerHTML() { return capturedHTML; }, + set innerHTML(v) { capturedHTML = v; }, + querySelectorAll: () => [], + }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'sidebar-list') return mockList; + return null; + }; + + // Set up single source + app._setSources([ + { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, + ]); + + app._setViewMode('fullscreen'); + const sessions = [ + { name: 'alpha', deviceName: 'Laptop', sourceUrl: '', sessionKey: '::alpha', snapshot: '', bell: { unseen_count: 0 } }, + { name: 'beta', deviceName: 'Laptop', sourceUrl: '', sessionKey: '::beta', snapshot: '', bell: { unseen_count: 0 } }, + ]; + app.renderSidebar(sessions, null); + + assert.ok(!capturedHTML.includes('sidebar-device-header'), 'sidebar HTML should NOT contain sidebar-device-header when only one source'); + assert.ok(capturedHTML.includes('sidebar-item'), 'sidebar HTML should still contain sidebar-item elements'); + + // Cleanup + app._setSources([]); + globalThis.document.getElementById = origGetById; + app._setViewMode('grid'); +});