` +
```
Also update the `data-session` attribute in the tile to use `sessionKey` when available (for unique identification). Find:
```javascript
`` +
```
Replace with:
```javascript
`` +
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
```
Expected: All tests pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device badge to session tiles when multiple sources configured"
```
---
## Task 10: Device badge on sidebar items
**Files:**
- Modify: `muxplex/frontend/app.js` (`buildSidebarHTML`)
- Test: `muxplex/frontend/tests/test_app.mjs`
**Step 1: Write the failing test**
Add to end of `test_app.mjs`:
```javascript
// --- buildSidebarHTML device badge ---
test('buildSidebarHTML shows device-badge when multiple sources configured', () => {
app._setSources([
{ url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 },
{ url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 },
]);
const session = { name: 'main', deviceName: 'Work', sourceUrl: 'http://work:8088', snapshot: '' };
const html = app.buildSidebarHTML(session, null);
assert.ok(html.includes('device-badge'), 'should include device-badge');
assert.ok(html.includes('Work'), 'badge should show Work');
app._setSources([]);
});
```
**Step 2: Run test to verify it fails**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
```
Expected: FAIL — `buildSidebarHTML` doesn't produce `device-badge`.
**Step 3: Update `buildSidebarHTML`**
In `muxplex/frontend/app.js`, find the `buildSidebarHTML` function (around line 420). Find the sidebar name span:
```javascript
`${escapedName}` +
```
Replace with:
```javascript
`${escapedName}${_sources.length > 1 && session.deviceName ? '' + escapeHtml(session.deviceName) + '' : ''}` +
```
Also update the data attribute for session key. Find:
```javascript
`` +
```
Replace with:
```javascript
`` +
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
```
Expected: All tests pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device badge to sidebar items when multiple sources configured"
```
---
## Task 11: Grouped view mode rendering
**Files:**
- Modify: `muxplex/frontend/app.js` (`renderGrid`)
- Test: `muxplex/frontend/tests/test_app.mjs`
**Step 1: Write the failing test**
Add to end of `test_app.mjs`:
```javascript
// --- renderGrid grouped view mode ---
test('renderGrid in grouped mode produces device-group-header elements', () => {
const mockGrid = { innerHTML: '' };
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;
if (id === 'filter-bar') return null;
return null;
};
globalThis.document.querySelectorAll = () => [];
app._setSources([
{ url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 },
{ url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 },
]);
app._setServerSettings({});
// Temporarily set grid view mode
app._setGridViewMode('grouped');
const sessions = [
{ name: 'sess-a', deviceName: 'Laptop', sourceUrl: '', snapshot: '', sessionKey: '::sess-a' },
{ name: 'sess-b', deviceName: 'Work', sourceUrl: 'http://work:8088', snapshot: '', sessionKey: 'http://work:8088::sess-b' },
];
app.renderGrid(sessions);
assert.ok(mockGrid.innerHTML.includes('device-group-header'), 'grouped mode should include device-group-header');
assert.ok(mockGrid.innerHTML.includes('Laptop'), 'should show Laptop header');
assert.ok(mockGrid.innerHTML.includes('Work'), 'should show Work header');
app._setGridViewMode('flat');
app._setSources([]);
globalThis.document.getElementById = origGetById;
globalThis.document.querySelectorAll = origQSA;
});
```
**Step 2: Run test to verify it fails**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
```
Expected: FAIL — `app._setGridViewMode is not a function` (or no device-group-header in output).
**Step 3: Implement**
First, add a test-only setter for `_gridViewMode`. In app.js, after `_getGridViewMode`:
```javascript
/** Test-only: set _gridViewMode directly. */
function _setGridViewMode(mode) {
_gridViewMode = mode;
}
```
Add `_setGridViewMode` to `module.exports`.
Now update `renderGrid`. Replace the entire `renderGrid` function with:
```javascript
function renderGrid(sessions) {
var grid = $('session-grid');
var emptyState = $('empty-state');
var filterBar = $('filter-bar');
var visible = getVisibleSessions(sessions);
// In filtered mode, apply device filter
if (_gridViewMode === 'filtered' && _activeFilterDevice !== 'all') {
visible = visible.filter(function(s) { return s.deviceName === _activeFilterDevice; });
}
if (visible.length === 0) {
if (grid) grid.innerHTML = '';
if (emptyState) emptyState.classList.remove('hidden');
// Show filter bar even when filtered to empty (so user can switch back)
if (filterBar) {
if (_gridViewMode === 'filtered') {
renderFilterBar(filterBar, sessions);
} else {
filterBar.innerHTML = '';
}
}
return;
}
if (emptyState) emptyState.classList.add('hidden');
// Apply sort order from server settings
var sortOrder = _serverSettings && _serverSettings.sort_order;
var mobile = isMobile();
var ordered;
if (sortOrder === 'alphabetical') {
ordered = visible.slice().sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
} else {
ordered = mobile ? sortByPriority(visible) : visible;
}
var html;
if (_gridViewMode === 'grouped') {
html = renderGroupedGrid(ordered, mobile);
} else {
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
}
if (grid) grid.innerHTML = html;
// Render filter bar
if (filterBar) {
if (_gridViewMode === 'filtered') {
renderFilterBar(filterBar, sessions);
} else {
filterBar.innerHTML = '';
}
}
// Bind interaction handlers on each tile
document.querySelectorAll('.session-tile').forEach(function(tile) {
on(tile, 'click', function() { openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl }); });
on(tile, 'keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl });
}
});
});
if (_viewMode === 'fullscreen') {
updatePillBell();
}
}
```
Add the `renderGroupedGrid` helper right before `renderGrid`:
```javascript
/**
* Render sessions grouped by device name. Returns HTML string.
* @param {object[]} sessions - sorted, visible sessions
* @param {boolean} mobile
* @returns {string}
*/
function renderGroupedGrid(sessions, mobile) {
// Group by deviceName
var groups = {};
var groupOrder = [];
for (var i = 0; i < sessions.length; i++) {
var dn = sessions[i].deviceName || 'Unknown';
if (!groups[dn]) {
groups[dn] = [];
groupOrder.push(dn);
}
groups[dn].push(sessions[i]);
}
var html = '';
for (var g = 0; g < groupOrder.length; g++) {
var name = groupOrder[g];
html += '
' + escapeHtml(name) + '
';
var groupSessions = groups[name];
for (var j = 0; j < groupSessions.length; j++) {
html += buildTileHTML(groupSessions[j], j, mobile);
}
}
return html;
}
```
Add `renderGroupedGrid` to `module.exports`.
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
```
Expected: All tests pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add grouped view mode to renderGrid with device-group-header sections"
```
---
## Task 12: Filtered view mode with pill bar
**Files:**
- Modify: `muxplex/frontend/app.js`
- Modify: `muxplex/frontend/index.html`
- Test: `muxplex/frontend/tests/test_app.mjs`
**Step 1: Add filter bar container to index.html**
In `muxplex/frontend/index.html`, find line 29:
```html
```
Add the filter bar container BEFORE it:
```html
```
**Step 2: Write the failing test**
Add to end of `test_app.mjs`:
```javascript
// --- renderFilterBar ---
test('renderFilterBar produces pill buttons for each device plus All', () => {
const mockBar = { innerHTML: '' };
const sessions = [
{ name: 'a', deviceName: 'Laptop', sourceUrl: '' },
{ name: 'b', deviceName: 'Work', sourceUrl: 'http://work:8088' },
];
app.renderFilterBar(mockBar, sessions);
assert.ok(mockBar.innerHTML.includes('filter-pill'), 'should contain filter-pill elements');
assert.ok(mockBar.innerHTML.includes('All'), 'should contain All pill');
assert.ok(mockBar.innerHTML.includes('Laptop'), 'should contain Laptop pill');
assert.ok(mockBar.innerHTML.includes('Work'), 'should contain Work pill');
});
test('renderFilterBar marks active device pill with filter-pill--active', () => {
const mockBar = { innerHTML: '' };
app._setActiveFilterDevice('Work');
const sessions = [
{ name: 'a', deviceName: 'Laptop', sourceUrl: '' },
{ name: 'b', deviceName: 'Work', sourceUrl: 'http://work:8088' },
];
app.renderFilterBar(mockBar, sessions);
// The "Work" pill should have filter-pill--active
assert.ok(mockBar.innerHTML.includes('filter-pill--active'), 'active pill should have active class');
app._setActiveFilterDevice('all');
});
```
**Step 3: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
```
Expected: FAIL — `app.renderFilterBar is not a function`.
**Step 4: Implement `renderFilterBar` and helpers**
In `muxplex/frontend/app.js`, add after `renderGroupedGrid`:
```javascript
/**
* Render the device filter pill bar into the given container element.
* @param {HTMLElement} container
* @param {object[]} allSessions - unfiltered sessions (for device name extraction)
*/
function renderFilterBar(container, allSessions) {
// Collect unique device names in order
var devices = [];
var seen = {};
for (var i = 0; i < (allSessions || []).length; i++) {
var dn = allSessions[i].deviceName || 'Unknown';
if (!seen[dn]) {
seen[dn] = true;
devices.push(dn);
}
}
var html = '';
for (var d = 0; d < devices.length; d++) {
var active = _activeFilterDevice === devices[d] ? ' filter-pill--active' : '';
html += '';
}
container.innerHTML = html;
}
```
Add a test-only setter for `_activeFilterDevice`:
```javascript
/** Test-only: set _activeFilterDevice directly. */
function _setActiveFilterDevice(device) {
_activeFilterDevice = device;
}
```
Add to `module.exports`:
```javascript
renderFilterBar,
_setActiveFilterDevice,
renderGroupedGrid,
```
**Step 5: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
```
Expected: All tests pass.
**Step 6: Bind filter pill click handlers**
In `muxplex/frontend/app.js`, find the `bindStaticEventListeners` function. Add this block at the end of the function body (just before the closing `}`):
```javascript
// Filter bar pill clicks (delegated \u2014 pills are re-rendered each poll)
var filterBarEl = $('filter-bar');
if (filterBarEl) {
filterBarEl.addEventListener('click', function(e) {
var pill = e.target.closest('.filter-pill');
if (!pill) return;
_activeFilterDevice = pill.dataset.filterDevice || 'all';
// Re-render with current sessions
renderGrid(_currentSessions);
});
}
```
**Step 7: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/index.html muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add filtered view mode with device pill bar"
```
---
## Task 13: View preference storage
**Files:**
- Modify: `muxplex/frontend/app.js`
- Test: `muxplex/frontend/tests/test_app.mjs`
**Step 1: Write the failing tests**
Add to end of `test_app.mjs`:
```javascript
// --- view preference storage ---
test('loadGridViewMode returns flat by default', () => {
_localStorageStore = {};
const mode = app.loadGridViewMode();
assert.strictEqual(mode, 'flat');
});
test('loadGridViewMode reads from localStorage when scope is local', () => {
_localStorageStore = {};
_localStorageStore['muxplex.display'] = JSON.stringify({ viewPreferenceScope: 'local', gridViewMode: 'grouped' });
const mode = app.loadGridViewMode();
assert.strictEqual(mode, 'grouped');
_localStorageStore = {};
});
test('loadGridViewMode reads from serverSettings when scope is server', () => {
_localStorageStore = {};
_localStorageStore['muxplex.display'] = JSON.stringify({ viewPreferenceScope: 'server' });
app._setServerSettings({ grid_view_mode: 'filtered' });
const mode = app.loadGridViewMode();
assert.strictEqual(mode, 'filtered');
app._setServerSettings(null);
_localStorageStore = {};
});
test('saveGridViewMode stores to localStorage when scope is local', () => {
_localStorageStore = {};
_localStorageStore['muxplex.display'] = JSON.stringify({ viewPreferenceScope: 'local' });
app.saveGridViewMode('grouped');
const ds = JSON.parse(_localStorageStore['muxplex.display']);
assert.strictEqual(ds.gridViewMode, 'grouped');
_localStorageStore = {};
});
```
**Step 2: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
```
Expected: FAIL — `app.loadGridViewMode is not a function`.
**Step 3: Implement**
In `muxplex/frontend/app.js`, add after the `saveDisplaySettings` function:
```javascript
/**
* Load the grid view mode preference based on the view_preference_scope setting.
* @returns {'flat'|'grouped'|'filtered'}
*/
function loadGridViewMode() {
var ds = loadDisplaySettings();
var scope = ds.viewPreferenceScope || 'local';
if (scope === 'server') {
return (_serverSettings && _serverSettings.grid_view_mode) || 'flat';
}
return ds.gridViewMode || 'flat';
}
/**
* Save the grid view mode preference based on current scope.
* @param {'flat'|'grouped'|'filtered'} mode
*/
function saveGridViewMode(mode) {
var ds = loadDisplaySettings();
var scope = ds.viewPreferenceScope || 'local';
if (scope === 'server') {
patchServerSetting('grid_view_mode', mode);
} else {
ds.gridViewMode = mode;
saveDisplaySettings(ds);
}
_gridViewMode = mode;
}
```
Wire the view mode into startup. In the `DOMContentLoaded` handler, after `applyDisplaySettings(loadDisplaySettings());` (line ~1640), add:
```javascript
_gridViewMode = loadGridViewMode();
```
Add to `module.exports`:
```javascript
loadGridViewMode,
saveGridViewMode,
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
```
Expected: All tests pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add view preference storage with local/server scope"
```
---
## Task 14: Settings dialog — view mode selector and scope toggle
**Files:**
- Modify: `muxplex/frontend/index.html`
- Modify: `muxplex/frontend/app.js` (`openSettings`, `bindStaticEventListeners`)
**Step 1: Add view mode controls to Display tab in index.html**
In `muxplex/frontend/index.html`, find the end of the Display settings panel (the `` that closes `data-tab="display"`, around line 123). Add BEFORE that closing ``:
```html
```
**Step 2: Populate view mode controls in `openSettings`**
In `muxplex/frontend/app.js`, find the `openSettings` function. After the line that sets `gridColumnsEl` value (around line 1096), add:
```javascript
// View mode selector
var viewModeEl = $('setting-view-mode');
if (viewModeEl) viewModeEl.value = _gridViewMode || 'flat';
var viewScopeEl = $('setting-view-scope');
if (viewScopeEl) viewScopeEl.value = settings.viewPreferenceScope || 'local';
```
**Step 3: Bind change handlers in `bindStaticEventListeners`**
In the `bindStaticEventListeners` function, after the existing display settings bindings (after the `on($('setting-grid-columns'), 'change', onDisplaySettingChange);` line), add:
```javascript
// View mode selector — save preference and re-render grid
on($('setting-view-mode'), 'change', function() {
var el = $('setting-view-mode');
if (el) {
saveGridViewMode(el.value);
renderGrid(_currentSessions);
renderSidebar(_currentSessions, _viewingSession);
}
});
// View preference scope toggle
on($('setting-view-scope'), 'change', function() {
var el = $('setting-view-scope');
if (el) {
var ds = loadDisplaySettings();
ds.viewPreferenceScope = el.value;
saveDisplaySettings(ds);
// Migrate current mode to new scope
saveGridViewMode(_gridViewMode);
}
});
```
**Step 4: Run all tests**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
```
Expected: All tests pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/index.html muxplex/frontend/app.js && git commit -m "feat: add view mode selector and scope toggle to settings dialog"
```
---
## Task 15: Settings dialog — Remote Instances management
**Files:**
- Modify: `muxplex/frontend/index.html`
- Modify: `muxplex/frontend/app.js`
**Step 1: Add Remote Instances section to Sessions tab in index.html**
In `muxplex/frontend/index.html`, find the Sessions settings panel (`data-tab="sessions"`). Add BEFORE the closing `` of that panel:
```html
How this instance appears on other dashboards
```
**Step 2: Populate remote instances in `openSettings`**
In `muxplex/frontend/app.js`, inside the `openSettings` function, inside the `.then(function(ss) {` callback, after the auto-open section (around line 1162), add:
```javascript
// Device name
var deviceNameEl = $('setting-device-name');
if (deviceNameEl) {
deviceNameEl.value = (ss && ss.device_name) || '';
}
// Remote instances list
var remoteEl = $('setting-remote-instances');
if (remoteEl) {
remoteEl.innerHTML = '';
var instances = (ss && ss.remote_instances) || [];
for (var i = 0; i < instances.length; i++) {
var inst = instances[i];
var row = document.createElement('div');
row.className = 'settings-remote-row';
row.innerHTML =
'' +
'' +
'';
remoteEl.appendChild(row);
}
}
```
**Step 3: Bind change handlers in `bindStaticEventListeners`**
In `bindStaticEventListeners`, add these blocks:
```javascript
// Device name — debounced save
var _deviceNameTimer;
on($('setting-device-name'), 'input', function() {
clearTimeout(_deviceNameTimer);
var val = this.value;
_deviceNameTimer = setTimeout(function() {
patchServerSetting('device_name', val);
}, 500);
});
// Add remote instance button
on($('add-remote-instance-btn'), 'click', function() {
var container = $('setting-remote-instances');
if (!container) return;
var row = document.createElement('div');
row.className = 'settings-remote-row';
row.innerHTML =
'' +
'' +
'';
container.appendChild(row);
});
// Remote instances — delegated handlers for remove + change
var remoteContainer = $('setting-remote-instances');
if (remoteContainer) {
// Remove button
remoteContainer.addEventListener('click', function(e) {
var removeBtn = e.target.closest('.settings-remote-remove');
if (!removeBtn) return;
var row = removeBtn.closest('.settings-remote-row');
if (row) row.remove();
_saveRemoteInstances();
});
// Save on input change (debounced)
var _remoteTimer;
remoteContainer.addEventListener('input', function() {
clearTimeout(_remoteTimer);
_remoteTimer = setTimeout(_saveRemoteInstances, 500);
});
}
```
Add a `_saveRemoteInstances` helper function before `bindStaticEventListeners`:
```javascript
/**
* Read current remote instance rows from the settings dialog and save to server.
*/
function _saveRemoteInstances() {
var container = $('setting-remote-instances');
if (!container) return;
var rows = container.querySelectorAll('.settings-remote-row');
var instances = [];
rows.forEach(function(row) {
var urlInput = row.querySelector('.settings-remote-url');
var nameInput = row.querySelector('.settings-remote-name');
var url = urlInput ? urlInput.value.trim() : '';
var name = nameInput ? nameInput.value.trim() : '';
if (url) {
instances.push({ url: url, name: name || url });
}
});
patchServerSetting('remote_instances', instances);
// Rebuild sources immediately
if (_serverSettings) {
_serverSettings.remote_instances = instances;
_sources = buildSources(_serverSettings);
}
}
```
**Step 4: Add minimal CSS for remote instance rows**
In `muxplex/frontend/style.css`, add before the responsive overlay sidebar block:
```css
/* ============================================================
Settings: Remote instances
============================================================ */
.settings-remote-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings-remote-row {
display: flex;
gap: 6px;
align-items: center;
}
.settings-remote-url {
flex: 2;
}
.settings-remote-name {
flex: 1;
}
.settings-remote-remove {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--err);
cursor: pointer;
width: 28px;
height: 28px;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.settings-remote-remove:hover {
border-color: var(--err);
background: rgba(248, 81, 73, 0.1);
}
.settings-input {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
padding: 6px 8px;
font-size: 13px;
font-family: var(--font-ui);
}
.settings-input:focus {
border-color: var(--accent);
outline: none;
}
```
**Step 5: Run all tests**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
```
Expected: All tests pass.
**Step 6: Commit**
```bash
cd muxplex && git add muxplex/frontend/index.html muxplex/frontend/app.js muxplex/frontend/style.css && git commit -m "feat: add remote instances management to settings dialog"
```
---
## Task 16: Sidebar device grouping
**Files:**
- Modify: `muxplex/frontend/app.js` (`renderSidebar`)
- Test: `muxplex/frontend/tests/test_app.mjs`
**Step 1: Write the failing test**
Add to end of `test_app.mjs`:
```javascript
// --- renderSidebar device grouping ---
test('renderSidebar groups sessions by device 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;
};
app._setViewMode('fullscreen');
app._setSources([
{ url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 },
{ url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 },
]);
app._setServerSettings({});
const sessions = [
{ name: 'sess-a', deviceName: 'Laptop', sourceUrl: '', snapshot: '', sessionKey: '::sess-a' },
{ name: 'sess-b', deviceName: 'Work', sourceUrl: 'http://work:8088', snapshot: '', sessionKey: 'http://work:8088::sess-b' },
];
app.renderSidebar(sessions, null);
assert.ok(capturedHTML.includes('sidebar-device-header'), 'should include device group headers');
assert.ok(capturedHTML.includes('Laptop'), 'should include Laptop header');
assert.ok(capturedHTML.includes('Work'), 'should include Work header');
app._setViewMode('grid');
app._setSources([]);
globalThis.document.getElementById = origGetById;
});
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;
};
app._setViewMode('fullscreen');
app._setSources([
{ url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 },
]);
app._setServerSettings({});
const sessions = [
{ name: 'sess-a', deviceName: 'Laptop', sourceUrl: '', snapshot: '', sessionKey: '::sess-a' },
];
app.renderSidebar(sessions, null);
assert.ok(!capturedHTML.includes('sidebar-device-header'), 'should NOT include device group headers with single source');
app._setViewMode('grid');
app._setSources([]);
globalThis.document.getElementById = origGetById;
});
```
**Step 2: Run tests to verify they fail**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
```
Expected: FAIL — current `renderSidebar` doesn't produce `sidebar-device-header`.
**Step 3: Update `renderSidebar`**
Replace the entire `renderSidebar` function in `muxplex/frontend/app.js`:
```javascript
function renderSidebar(sessions, currentSession) {
if (_viewMode !== 'fullscreen') return;
var list = $('sidebar-list');
if (!list) return;
var visible = getVisibleSessions(sessions);
if (visible.length === 0) {
list.innerHTML = '
No sessions
';
return;
}
var html;
if (_sources.length > 1) {
// Group by deviceName
var groups = {};
var groupOrder = [];
for (var i = 0; i < visible.length; i++) {
var dn = visible[i].deviceName || 'Unknown';
if (!groups[dn]) {
groups[dn] = [];
groupOrder.push(dn);
}
groups[dn].push(visible[i]);
}
html = '';
for (var g = 0; g < groupOrder.length; g++) {
html += '
' + escapeHtml(groupOrder[g]) + '
';
var groupSessions = groups[groupOrder[g]];
for (var j = 0; j < groupSessions.length; j++) {
html += buildSidebarHTML(groupSessions[j], currentSession);
}
}
} else {
html = visible.map(function(session) { return buildSidebarHTML(session, currentSession); }).join('');
}
list.innerHTML = html;
// Bind click handlers on each sidebar item
if (typeof list.querySelectorAll === 'function') {
list.querySelectorAll('.sidebar-item').forEach(function(item) {
var name = item.dataset.session;
var sourceUrl = item.dataset.sourceUrl || '';
on(item, 'click', function() {
if (name !== currentSession) openSession(name, { sourceUrl: sourceUrl });
});
});
}
}
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
```
Expected: All tests pass.
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device grouping to sidebar when multiple sources configured"
```
---
## Task 17: Update `openSession` to pass sourceUrl through
**Files:**
- Modify: `muxplex/frontend/app.js`
Phase 3 will handle remote terminal connections. For now, `openSession` needs to accept `sourceUrl` in its options and store it, but only actually connect/open terminal for local sessions.
**Step 1: Update `openSession` signature**
In `muxplex/frontend/app.js`, find the `openSession` function (around line 827). The function signature already has `opts = {}`. That's fine. Update the connect call to use sourceUrl when it's provided.
Find this block:
```javascript
// Connect to session (kill old ttyd, spawn new one for this session)
try {
if (!opts.skipConnect) {
await api('POST', `/api/sessions/${name}/connect`);
}
} catch (err) {
```
Replace with:
```javascript
// Connect to session (kill old ttyd, spawn new one for this session)
var sourceUrl = opts.sourceUrl || '';
try {
if (!opts.skipConnect) {
// Phase 2: only connect to local sessions. Remote connect is Phase 3.
if (!sourceUrl) {
await api('POST', `/api/sessions/${name}/connect`);
}
}
} catch (err) {
```
And update the terminal mount to also skip for remote:
Find:
```javascript
// Mount terminal NOW — /connect has completed, new ttyd is serving the correct session
if (window._openTerminal) window._openTerminal(name);
```
Replace with:
```javascript
// Mount terminal NOW — /connect has completed, new ttyd is serving the correct session
// Phase 2: only open terminal for local sessions. Remote terminal is Phase 3.
if (!sourceUrl && window._openTerminal) window._openTerminal(name);
```
**Step 2: Run all tests**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
```
Expected: All tests pass (existing openSession tests don't set sourceUrl, so they exercise the local path).
**Step 3: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js && git commit -m "feat: openSession accepts sourceUrl opt, skips connect/terminal for remote (Phase 3)"
```
---
## Task 18: Update `detectBellTransitions` for session keys
**Files:**
- Modify: `muxplex/frontend/app.js`
- Test: `muxplex/frontend/tests/test_app.mjs`
`detectBellTransitions` currently deduplicates by `s.name` — with federation, two devices can have sessions named "main". Use `sessionKey` instead.
**Step 1: Write the failing test**
Add to end of `test_app.mjs`:
```javascript
// --- detectBellTransitions with sessionKey ---
test('detectBellTransitions uses sessionKey to distinguish same-name sessions across devices', () => {
const prev = [
{ name: 'main', sessionKey: '::main', bell: { unseen_count: 0 } },
{ name: 'main', sessionKey: 'http://work:8088::main', bell: { unseen_count: 0 } },
];
const next = [
{ name: 'main', sessionKey: '::main', bell: { unseen_count: 0 } },
{ name: 'main', sessionKey: 'http://work:8088::main', bell: { unseen_count: 3 } },
];
const transitions = app.detectBellTransitions(prev, next);
// Should fire for remote main but not local main
assert.strictEqual(transitions.length, 1);
assert.strictEqual(transitions[0], 'main');
});
```
**Step 2: Run test to verify it fails**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
```
Expected: Might pass or fail depending on how `detectBellTransitions` uses name vs sessionKey. Let's check — the current implementation uses `s.name` as map key, which would collapse both "main" sessions into one. If prev has count 0 for "main" and next has count 3 for "main", it would fire once. But we need to make sure it uses `sessionKey` for accurate tracking.
**Step 3: Update `detectBellTransitions`**
In `muxplex/frontend/app.js`, replace the `detectBellTransitions` function:
```javascript
function detectBellTransitions(prev, next) {
var prevMap = new Map(
(prev || []).map(function(s) {
return [s.sessionKey || s.name, (s.bell && s.bell.unseen_count) || 0];
}),
);
return (next || [])
.filter(function(s) {
var unseen = s.bell && s.bell.unseen_count;
if (!unseen || unseen <= 0) return false;
var key = s.sessionKey || s.name;
var prevCount = prevMap.has(key) ? prevMap.get(key) : 0;
return unseen > prevCount;
})
.map(function(s) { return s.name; });
}
```
**Step 4: Run tests to verify they pass**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
```
Expected: All tests pass (existing tests don't set sessionKey, so fallback to `s.name` still works).
**Step 5: Commit**
```bash
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: detectBellTransitions uses sessionKey to distinguish cross-device sessions"
```
---
## Task 19: Full integration test — all exports present
**Files:**
- Test: `muxplex/frontend/tests/test_app.mjs`
**Step 1: Write the integration test**
Add to end of `test_app.mjs`:
```javascript
// --- Phase 2 integration: all new exports present ---
test('app.js exports all Phase 2 federation functions', () => {
const phase2Functions = [
'api',
'buildSources',
'tagSessions',
'mergeSources',
'getVisibleSessions',
'renderGroupedGrid',
'renderFilterBar',
'loadGridViewMode',
'saveGridViewMode',
'_setSources',
'_getSources',
'_setServerSettings',
'_getGridViewMode',
'_setGridViewMode',
'_setActiveFilterDevice',
];
for (const fn of phase2Functions) {
assert.ok(fn in app, `app.js should export "${fn}"`);
assert.strictEqual(typeof app[fn], 'function', `"${fn}" should be a function`);
}
});
test('Phase 2 end-to-end: buildSources → tagSessions → mergeSources produces valid merged list', () => {
const settings = {
device_name: 'Laptop',
remote_instances: [{ url: 'http://work:8088', name: 'Work' }],
};
const sources = app.buildSources(settings);
assert.strictEqual(sources.length, 2);
const localSessions = [{ name: 'dev' }];
const remoteSessions = [{ name: 'dev' }, { name: 'prod' }];
const results = [
{ source: sources[0], sessions: localSessions },
{ source: sources[1], sessions: remoteSessions },
];
const merged = app.mergeSources(results);
assert.strictEqual(merged.length, 3);
// Both "dev" sessions should have different sessionKeys
const devSessions = merged.filter(function(s) { return s.name === 'dev'; });
assert.strictEqual(devSessions.length, 2);
assert.notStrictEqual(devSessions[0].sessionKey, devSessions[1].sessionKey);
});
```
**Step 2: Run all tests**
```bash
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
```
Expected: All tests pass.
**Step 3: Commit**
```bash
cd muxplex && git add muxplex/frontend/tests/test_app.mjs && git commit -m "test: add Phase 2 integration tests for federation exports and end-to-end flow"
```
---
## Summary Checklist
| Task | What it does | Key files |
|------|-------------|-----------|
| 1 | `api()` accepts `baseUrl` for cross-origin | `app.js` |
| 2 | `buildSources()` from settings | `app.js` |
| 3 | Test-only helpers for new state | `app.js` |
| 4 | `tagSessions()` + `mergeSources()` | `app.js` |
| 5 | Rewrite `pollSessions()` for multi-source | `app.js` |
| 6 | Wire sources into startup | `app.js` |
| 7 | `getVisibleSessions()` federation-aware | `app.js` |
| 8 | CSS for badges, headers, pills | `style.css` |
| 9 | Device badge on tiles | `app.js` |
| 10 | Device badge on sidebar items | `app.js` |
| 11 | Grouped view mode | `app.js` |
| 12 | Filtered view mode + pill bar | `app.js`, `index.html` |
| 13 | View preference storage | `app.js` |
| 14 | Settings: view mode + scope | `index.html`, `app.js` |
| 15 | Settings: remote instances | `index.html`, `app.js`, `style.css` |
| 16 | Sidebar device grouping | `app.js` |
| 17 | `openSession` sourceUrl passthrough | `app.js` |
| 18 | `detectBellTransitions` sessionKey | `app.js` |
| 19 | Integration test — all exports | `test_app.mjs` |
**Phase 2 end state:** Dashboard shows sessions from multiple instances with device tags and three view modes. Clicking a remote tile opens the fullscreen view but does NOT connect a terminal yet (Phase 3). Remote sources that return 401 are tracked as `auth_required` but no login UI is shown.