From c5921eba6533bfce91d542d1218d44afa8a6415a Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 17 May 2026 16:36:24 -0700 Subject: [PATCH] fix(ui): suppress empty device blocks in renderGrid grouped mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the v0.6.3 fix added a guard in renderGroupedGrid (groupSessions.length === 0) that is unreachable — groups are built by iterating the already-filtered session list, so every group that exists always has ≥1 entry. The actual empty-device block was coming from a separate code path in renderGrid that unconditionally appends a source-tile--empty status tile for every federation remote whose server returns {status: 'empty'}. In flat mode this is correct and intentional ("No sessions" badge); in grouped mode it produced a visible block showing the device name, exactly what the user saw for alienware-r13. The v0.6.3 tests only covered the hidden-sessions case (sessions exist but are hidden); they didn't cover the zero-sessions case (remote device online, zero tmux sessions → server emits status:empty sentinel). Both status-tile generation paths in renderGrid (the visible.length===0 early return and the main append-at-end path) were appending the empty tile regardless of gridViewMode. Fix: skip status:empty tiles when _gridViewMode === 'grouped'. auth_failed and unreachable tiles are still shown in all modes because they represent actionable error states. Adds three regression tests in test_app.mjs: - status:empty NOT rendered in grouped mode (would have caught the bug) - status:empty IS still rendered in flat mode (backward compat) - auth_failed/unreachable still appear in grouped mode --- muxplex/frontend/app.js | 13 ++- muxplex/frontend/tests/test_app.mjs | 134 ++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 82ee37f..54636f7 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1658,12 +1658,14 @@ function renderGrid(sessions) { var visible = getVisibleSessions(sessions); if (visible.length === 0) { - // Build status tiles for auth_failed/unreachable sessions even when no regular sessions exist + // Build status tiles for auth_failed/unreachable sessions even when no regular sessions exist. + // In grouped grid mode, skip status:empty tiles — empty devices are silently omitted so that + // a remote with zero tmux sessions doesn't produce a visible device block in the grouped view. var statusTilesHtml = ''; (sessions || []).forEach(function(session) { if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth'); else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline'); - else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty'); + else if (session.status === 'empty' && _gridViewMode !== 'grouped') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty'); }); if (grid) grid.innerHTML = statusTilesHtml; // Only show empty-state when there are truly no tiles at all @@ -1695,12 +1697,15 @@ function renderGrid(sessions) { html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join(''); } - // Append status tiles for auth_failed, unreachable, and empty sessions + // Append status tiles for auth_failed, unreachable, and empty sessions. + // In grouped grid mode, skip status:empty tiles — a remote with zero tmux sessions is simply + // omitted from the grouped view. auth_failed and unreachable are always shown because those + // are actionable error states regardless of view mode. var statusTilesHtml = ''; (sessions || []).forEach(function(session) { if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth'); else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline'); - else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty'); + else if (session.status === 'empty' && _gridViewMode !== 'grouped') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty'); }); if (grid) grid.innerHTML = html + statusTilesHtml; diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 4bc5284..60b23df 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -5584,6 +5584,140 @@ test('v0.6.3: grouped view still shows device header when device has at least on app._setActiveView('all'); }); +// ─── v0.6.4 empty-device-block regression ──────────────────────────────────── + +test('v0.6.4: status:empty block is NOT rendered in grouped grid mode for a remote with zero sessions', () => { + // The real user scenario (spark-1 viewing alienware-r13 via federation): + // alienware-r13 has zero tmux sessions → server emits {status:"empty", deviceName:"alienware-r13"}. + // spark-1 (the local device) still has its own sessions. + // BEFORE v0.6.4: renderGrid appended a source-tile--empty block for alienware-r13 even in + // grouped mode, showing the device name in the grid (the user's reported "device block"). + // AFTER v0.6.4: the status:empty tile is suppressed in grouped mode. + const sessions = [ + { name: 'local-sess', deviceName: 'spark-1', snapshot: '', sessionKey: 'spark-1:local-sess' }, + { status: 'empty', deviceName: 'alienware-r13', deviceId: 'aw-uuid', remoteId: 'aw-uuid' }, + ]; + app._setServerSettings({ multi_device_enabled: true, hidden_sessions: [] }); + app._setGridViewMode('grouped'); + app._setActiveView('all'); + + let capturedHTML = ''; + const mockGrid = { get innerHTML() { return capturedHTML; }, set innerHTML(v) { capturedHTML = v; } }; + const mockEmpty = { classList: { add: () => {}, remove: () => {} } }; + const origGetById = globalThis.document.getElementById; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.getElementById = (id) => { + if (id === 'session-grid') return mockGrid; + if (id === 'empty-state') return mockEmpty; + return null; + }; + globalThis.document.querySelectorAll = () => []; + + app.renderGrid(sessions); + + assert.ok( + !capturedHTML.includes('alienware-r13'), + 'alienware-r13 must NOT appear in grouped grid when the device has zero sessions; got: ' + capturedHTML + ); + assert.ok( + !capturedHTML.includes('source-tile--empty'), + 'source-tile--empty must NOT be rendered for an empty remote in grouped mode; got: ' + capturedHTML + ); + assert.ok( + capturedHTML.includes('spark-1') || capturedHTML.includes('local-sess'), + 'spark-1 local session must still appear; got: ' + capturedHTML + ); + + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; + app._setGridViewMode('flat'); + app._setServerSettings(null); + app._setActiveView('all'); +}); + +test('v0.6.4: status:empty block IS still rendered in flat grid mode', () => { + // In flat mode the "No sessions" tile for an empty remote is intentional — only + // the grouped view should suppress it. + const sessions = [ + { name: 'local-sess', deviceName: 'spark-1', snapshot: '', sessionKey: 'spark-1:local-sess' }, + { status: 'empty', deviceName: 'alienware-r13', deviceId: 'aw-uuid', remoteId: 'aw-uuid' }, + ]; + app._setServerSettings({ multi_device_enabled: true, hidden_sessions: [] }); + app._setGridViewMode('flat'); + app._setActiveView('all'); + + let capturedHTML = ''; + const mockGrid = { get innerHTML() { return capturedHTML; }, set innerHTML(v) { capturedHTML = v; } }; + const mockEmpty = { classList: { add: () => {}, remove: () => {} } }; + const origGetById = globalThis.document.getElementById; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.getElementById = (id) => { + if (id === 'session-grid') return mockGrid; + if (id === 'empty-state') return mockEmpty; + return null; + }; + globalThis.document.querySelectorAll = () => []; + + app.renderGrid(sessions); + + assert.ok( + capturedHTML.includes('source-tile--empty'), + 'source-tile--empty must still appear for an empty remote in flat mode; got: ' + capturedHTML + ); + assert.ok( + capturedHTML.includes('alienware-r13'), + 'alienware-r13 name must appear in the status tile in flat mode; got: ' + capturedHTML + ); + + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; + app._setGridViewMode('flat'); + app._setServerSettings(null); + app._setActiveView('all'); +}); + +test('v0.6.4: auth_failed and unreachable tiles still appear in grouped mode', () => { + // Only status:empty is suppressed in grouped mode; auth_failed and unreachable + // are actionable error states and must always be shown. + const sessions = [ + { name: 'local-sess', deviceName: 'spark-1', snapshot: '', sessionKey: 'spark-1:local-sess' }, + { status: 'auth_failed', deviceName: 'device-b', deviceId: 'b-uuid', remoteId: 'b-uuid' }, + { status: 'unreachable', deviceName: 'device-c', deviceId: 'c-uuid', remoteId: 'c-uuid' }, + ]; + app._setServerSettings({ multi_device_enabled: true, hidden_sessions: [] }); + app._setGridViewMode('grouped'); + app._setActiveView('all'); + + let capturedHTML = ''; + const mockGrid = { get innerHTML() { return capturedHTML; }, set innerHTML(v) { capturedHTML = v; } }; + const mockEmpty = { classList: { add: () => {}, remove: () => {} } }; + const origGetById = globalThis.document.getElementById; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.getElementById = (id) => { + if (id === 'session-grid') return mockGrid; + if (id === 'empty-state') return mockEmpty; + return null; + }; + globalThis.document.querySelectorAll = () => []; + + app.renderGrid(sessions); + + assert.ok( + capturedHTML.includes('source-tile--auth'), + 'auth_failed tile must appear in grouped mode; got: ' + capturedHTML + ); + assert.ok( + capturedHTML.includes('source-tile--offline'), + 'unreachable tile must appear in grouped mode; got: ' + capturedHTML + ); + + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; + app._setGridViewMode('flat'); + app._setServerSettings(null); + app._setActiveView('all'); +}); + test('v0.6.3: empty-state still appears when every device has zero visible sessions', () => { // When ALL sessions across ALL devices are hidden, visible.length === 0. // renderGrid() must still reach its early-return branch and show empty-state,