70 KiB
Multi-Device Federation — Phase 2: Frontend Multi-Origin + View Modes
Execution: Use the subagent-driven-development workflow to implement this plan.
Goal: Make the muxplex dashboard display sessions from multiple muxplex instances in a single grid, with device badges, three view modes (flat/grouped/filtered), and settings UI for managing remote instances.
Architecture: The browser fetches /api/sessions from every configured muxplex instance in parallel, tags each session with its source device name and URL, merges them into a single list, and renders them using the existing tile/sidebar system augmented with device badges and view-mode logic. All cross-origin requests use credentials: "include" so the browser sends cookies for each remote origin. Unreachable sources are tracked with exponential backoff but no placeholder tiles yet (Phase 3). No new backend routes — this phase is pure frontend (app.js, style.css, index.html).
Tech Stack: Vanilla JS (app.js), CSS (style.css), HTML (index.html), Node.js --test runner for JS tests
Design doc: docs/plans/2026-03-30-multi-device-federation-design.md
Orientation — What You Need to Know
Phase 1 delivers (already done before you start)
settings.pyhasremote_instances(list of{url, name}) anddevice_name(string) inDEFAULT_SETTINGSGET /api/instance-inforoute exists — no auth required, returns{"name": "DeviceName", "version": "0.1.0"}- CORS middleware is active on all instances (allows any origin)
/api/instance-infois in_AUTH_EXEMPT_PATHS(no cookie needed)
Project layout
muxplex/ ← project root (run all commands from here)
├── muxplex/
│ ├── main.py ← FastAPI app, routes (NOT modified in Phase 2)
│ ├── settings.py ← DEFAULT_SETTINGS dict w/ remote_instances, device_name
│ └── frontend/
│ ├── app.js ← 1724 lines, vanilla JS, CommonJS export shim at bottom
│ ├── style.css ← 1388 lines, CSS custom properties on :root
│ ├── index.html ← 183 lines, settings dialog, grid, sidebar
│ └── tests/
│ └── test_app.mjs ← 1955 lines, Node.js --test runner, globalThis stubs
Key patterns in app.js you MUST follow
- No framework, no bundler — plain functions, no classes, no imports
- DOM helpers:
$(id)→document.getElementById(id),on(el, ev, fn) - API wrapper:
async function api(method, path, body)— line 160, currently uses relative URLs - State globals:
_deviceId,_currentSessions,_viewingSession,_viewMode('grid'|'fullscreen'),_serverSettings,_pollFailCount - Poll pattern:
pollSessions()→GET /api/sessions→renderGrid→renderSidebar→handleBellTransitions→updateSessionPill - Settings:
loadServerSettings()→GET /api/settings→ caches in_serverSettings.patchServerSetting(key, value)→PATCH /api/settings - Display settings:
loadDisplaySettings()/saveDisplaySettings()uselocalStoragekeymuxplex.display - CommonJS export shim at bottom (line 1660):
if (typeof module !== 'undefined' && module.exports) { module.exports = { ... }; }— every new function you want to test MUST be added here - Test-only helpers:
_setCurrentSessions(sessions),_setViewMode(mode),_setViewingSession(name)— for setting internal state from tests
Key patterns in test_app.mjs you MUST follow
- Import:
const app = require(join(__dirname, '..', 'app.js')); - Stub globals BEFORE import at top of file —
globalThis.localStorage,globalThis.document,globalThis.window,globalThis.fetch - Per-test mocking pattern: Save original → replace → run test → restore original
- Flat
test()calls (not nesteddescribe/it) for most tests assert.*fromnode:assert/strict- Run command:
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs
Key patterns in style.css
- CSS custom properties on
:root:--bg,--accent,--bell,--tile-height,--tile-min-width, etc. - Sections delimited by
/* ============================================================ */comments - Grid:
.session-gridusesrepeat(auto-fill, minmax(var(--tile-min-width), 1fr)) - File ends at line 1388 with the
@media (max-width: 959px)block — add new sections BEFORE this final responsive block
Critical constraint: backward compatibility
api()must keep working with existing relative-URL calls (e.g.api('GET', '/api/sessions'))openSession(name)callswindow._openTerminal(name)— do NOT break this for local sessionsgetVisibleSessions()filters byhidden_sessionsserver setting — hidden filtering must still work after session taggingpollSessions()currently returns early in fullscreen mode with_viewMode === 'fullscreen'check — BUT actually it doesn't, it always polls. Keep it always polling.
Session identity model (critical)
Two different machines may have sessions named "main". After this phase, every session object gets two extra fields:
deviceName— e.g."Laptop","Workstation"sourceUrl— e.g.""(local),"http://work:8088"(remote)
The unique key for a session is the tuple (sourceUrl, name). Use sourceUrl + '::' + name as a string key whenever you need a unique identifier (e.g. in data-session-key attributes, hidden_sessions dedup).
Task 1: Refactor api() to accept optional base URL
Files:
- Modify:
muxplex/frontend/app.js(lines 159–171) - Test:
muxplex/frontend/tests/test_app.mjs
Step 1: Write the failing tests
Add these tests at the end of muxplex/frontend/tests/test_app.mjs (before the final empty line):
// --- api() with baseUrl ---
test('api with no baseUrl uses relative path (backward compat)', async () => {
const calls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (url, opts) => {
calls.push({ url, opts });
return { ok: true, json: async () => ({}) };
};
await app.api('GET', '/api/sessions');
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0].url, '/api/sessions');
assert.strictEqual(calls[0].opts.credentials, undefined);
globalThis.fetch = origFetch;
});
test('api with baseUrl prepends it to path and sets credentials include', async () => {
const calls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (url, opts) => {
calls.push({ url, opts });
return { ok: true, json: async () => ({}) };
};
await app.api('GET', '/api/sessions', undefined, 'http://work:8088');
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0].url, 'http://work:8088/api/sessions');
assert.strictEqual(calls[0].opts.credentials, 'include');
globalThis.fetch = origFetch;
});
test('api with baseUrl and trailing slash does not double-slash', async () => {
const calls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (url, opts) => {
calls.push({ url, opts });
return { ok: true, json: async () => ({}) };
};
await app.api('GET', '/api/sessions', undefined, 'http://work:8088/');
assert.strictEqual(calls[0].url, 'http://work:8088/api/sessions');
globalThis.fetch = origFetch;
});
Step 2: Run tests to verify they fail
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20
Expected: FAIL — app.api currently only accepts 3 args, the baseUrl parameter is ignored.
Step 3: Implement the api() refactor
In muxplex/frontend/app.js, replace the api function (lines 160–171) with:
async function api(method, path, body, baseUrl) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
let url = path;
if (baseUrl) {
// Strip trailing slash from baseUrl to avoid double-slash
url = baseUrl.replace(/\/+$/, '') + path;
opts.credentials = 'include';
}
const res = await fetch(url, opts);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res;
}
Also add api to the module.exports block (it's not currently exported). Find the line pollSessions, inside the module.exports block and add api, right before it:
// Fetch wrapper
api,
pollSessions,
Step 4: Run tests to verify they pass
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: refactor api() to accept optional baseUrl for cross-origin requests"
Task 2: Build sources model from server settings
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:
// --- buildSources ---
test('buildSources returns only local source when no remote_instances', () => {
const sources = app.buildSources({ device_name: 'Laptop' });
assert.strictEqual(sources.length, 1);
assert.deepStrictEqual(sources[0], { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 });
});
test('buildSources returns local + remote sources from remote_instances', () => {
const sources = app.buildSources({
device_name: 'Laptop',
remote_instances: [
{ url: 'http://work:8088', name: 'Workstation' },
{ url: 'https://dev:8088', name: 'Dev Server' },
],
});
assert.strictEqual(sources.length, 3);
assert.strictEqual(sources[0].type, 'local');
assert.strictEqual(sources[0].name, 'Laptop');
assert.strictEqual(sources[1].type, 'remote');
assert.strictEqual(sources[1].url, 'http://work:8088');
assert.strictEqual(sources[1].name, 'Workstation');
assert.strictEqual(sources[1].status, 'authenticated');
assert.strictEqual(sources[2].url, 'https://dev:8088');
});
test('buildSources uses hostname fallback when device_name is empty', () => {
const sources = app.buildSources({});
assert.strictEqual(sources.length, 1);
assert.strictEqual(sources[0].name, 'This device');
});
test('buildSources strips trailing slash from remote URLs', () => {
const sources = app.buildSources({
device_name: 'Laptop',
remote_instances: [{ url: 'http://work:8088/', name: 'Work' }],
});
assert.strictEqual(sources[1].url, 'http://work:8088');
});
Step 2: Run tests to verify they fail
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
Expected: FAIL — app.buildSources is not a function.
Step 3: Implement buildSources
In muxplex/frontend/app.js, add this function right after the api() function (after line ~175 post-Task 1):
/**
* Build the list of session sources from server settings.
* Local source is always first with url: "".
* @param {object} settings - server settings object
* @returns {object[]} array of source objects
*/
function buildSources(settings) {
var localName = (settings && settings.device_name) || 'This device';
var sources = [
{ url: '', name: localName, type: 'local', status: 'authenticated', backoffMs: 2000 },
];
var remotes = (settings && settings.remote_instances) || [];
for (var i = 0; i < remotes.length; i++) {
var r = remotes[i];
if (r && r.url) {
sources.push({
url: r.url.replace(/\/+$/, ''),
name: r.name || r.url,
type: 'remote',
status: 'authenticated',
backoffMs: 2000,
});
}
}
return sources;
}
Add a new global right after the existing let _pollFailCount = 0; line (around line 127):
let _sources = [];
Add buildSources and _sources to the module.exports block. Find the api, line you added in Task 1 and add after it:
buildSources,
Step 4: Run tests to verify they pass
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add buildSources to construct session source list from settings"
Task 3: Add test-only helpers for new state
Files:
- Modify:
muxplex/frontend/app.js - Test:
muxplex/frontend/tests/test_app.mjs
We need test helpers to set _sources and _serverSettings from tests, plus a _getGridViewMode() getter for later tasks.
Step 1: Write the failing tests
Add to end of test_app.mjs:
// --- test-only helpers for federation state ---
test('_setSources sets internal _sources array', () => {
const sources = [{ url: '', name: 'Test', type: 'local', status: 'authenticated', backoffMs: 2000 }];
app._setSources(sources);
// Verify via buildSources that _sources is set (we'll use it in later tests)
assert.ok(true, '_setSources should not throw');
});
test('_setServerSettings sets internal _serverSettings', () => {
app._setServerSettings({ device_name: 'TestDevice', remote_instances: [] });
assert.ok(true, '_setServerSettings should not throw');
});
Step 2: Run tests to verify they fail
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
Expected: FAIL — app._setSources is not a function.
Step 3: Implement helpers
In muxplex/frontend/app.js, add these after the existing _setViewMode function (around line 1636):
/** Test-only: set _sources directly. */
function _setSources(sources) {
_sources = sources;
}
/** Test-only: set _serverSettings directly. */
function _setServerSettings(settings) {
_serverSettings = settings;
}
/** Test-only: get current _gridViewMode. */
function _getGridViewMode() {
return _gridViewMode;
}
/** Test-only: get current _sources. */
function _getSources() {
return _sources;
}
Also add a new global after let _sources = []; (from Task 2):
let _gridViewMode = 'flat';
let _activeFilterDevice = 'all';
Add to module.exports:
_setSources,
_setServerSettings,
_getGridViewMode,
_getSources,
Step 4: Run tests to verify they pass
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add test-only helpers for federation state (_setSources, _setServerSettings)"
Task 4: Multi-source parallel polling with session tagging
Files:
- Modify:
muxplex/frontend/app.js(thepollSessionsfunction) - Test:
muxplex/frontend/tests/test_app.mjs
Step 1: Write the failing tests
Add to end of test_app.mjs:
// --- tagSessions ---
test('tagSessions adds deviceName and sourceUrl to each session', () => {
const sessions = [{ name: 'main' }, { name: 'dev' }];
const tagged = app.tagSessions(sessions, 'Laptop', '');
assert.strictEqual(tagged[0].deviceName, 'Laptop');
assert.strictEqual(tagged[0].sourceUrl, '');
assert.strictEqual(tagged[1].deviceName, 'Laptop');
});
test('tagSessions adds sessionKey as sourceUrl::name', () => {
const sessions = [{ name: 'main' }];
const tagged = app.tagSessions(sessions, 'Work', 'http://work:8088');
assert.strictEqual(tagged[0].sessionKey, 'http://work:8088::main');
});
test('tagSessions returns empty array for empty input', () => {
const tagged = app.tagSessions([], 'Laptop', '');
assert.deepStrictEqual(tagged, []);
});
// --- mergeSources ---
test('mergeSources combines sessions from multiple sources', () => {
const results = [
{ source: { url: '', name: 'Laptop' }, sessions: [{ name: 'main' }] },
{ source: { url: 'http://work:8088', name: 'Work' }, sessions: [{ name: 'main' }, { name: 'dev' }] },
];
const merged = app.mergeSources(results);
assert.strictEqual(merged.length, 3);
assert.strictEqual(merged[0].sessionKey, '::main');
assert.strictEqual(merged[1].sessionKey, 'http://work:8088::main');
assert.strictEqual(merged[2].sessionKey, 'http://work:8088::dev');
});
test('mergeSources returns empty array when all sources failed', () => {
const merged = app.mergeSources([]);
assert.deepStrictEqual(merged, []);
});
Step 2: Run tests to verify they fail
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
Expected: FAIL — app.tagSessions is not a function.
Step 3: Implement tagSessions and mergeSources
In muxplex/frontend/app.js, add these functions after buildSources:
/**
* Tag each session with its source device name, URL, and a unique key.
* Does NOT mutate originals — returns new objects.
* @param {object[]} sessions
* @param {string} deviceName
* @param {string} sourceUrl
* @returns {object[]}
*/
function tagSessions(sessions, deviceName, sourceUrl) {
return (sessions || []).map(function(s) {
return Object.assign({}, s, {
deviceName: deviceName,
sourceUrl: sourceUrl,
sessionKey: sourceUrl + '::' + (s.name || ''),
});
});
}
/**
* Merge tagged session arrays from multiple successful source fetches.
* @param {Array<{source: object, sessions: object[]}>} results
* @returns {object[]}
*/
function mergeSources(results) {
var all = [];
for (var i = 0; i < results.length; i++) {
var r = results[i];
var tagged = tagSessions(r.sessions, r.source.name, r.source.url);
for (var j = 0; j < tagged.length; j++) {
all.push(tagged[j]);
}
}
return all;
}
Add both to module.exports:
tagSessions,
mergeSources,
Step 4: Run tests to verify they pass
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add tagSessions and mergeSources for multi-origin session merging"
Task 5: Rewrite pollSessions for multi-source polling
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:
// --- pollSessions multi-source ---
test('pollSessions fetches from all sources and merges results', async () => {
const origGetById = globalThis.document.getElementById;
const origQSA = globalThis.document.querySelectorAll;
globalThis.document.getElementById = (id) => {
if (id === 'connection-status') return { textContent: '', className: '' };
if (id === 'session-grid') return { innerHTML: '' };
if (id === 'empty-state') return { classList: { add: () => {}, remove: () => {} } };
return null;
};
globalThis.document.querySelectorAll = () => [];
// Set up 2 sources
app._setSources([
{ url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 },
{ url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 },
]);
const fetchCalls = [];
globalThis.fetch = async (url, opts) => {
fetchCalls.push(url);
if (url === '/api/sessions') {
return { ok: true, json: async () => [{ name: 'local-sess' }] };
}
if (url === 'http://work:8088/api/sessions') {
return { ok: true, json: async () => [{ name: 'remote-sess' }] };
}
return { ok: true, json: async () => [] };
};
await app.pollSessions();
assert.ok(fetchCalls.includes('/api/sessions'), 'should fetch local sessions');
assert.ok(fetchCalls.includes('http://work:8088/api/sessions'), 'should fetch remote sessions');
globalThis.document.getElementById = origGetById;
globalThis.document.querySelectorAll = origQSA;
globalThis.fetch = undefined;
// Reset sources to default
app._setSources([]);
});
Step 2: Run tests to verify they fail
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
Expected: FAIL or unexpected behavior — the current pollSessions doesn't use _sources.
Step 3: Rewrite pollSessions
In muxplex/frontend/app.js, replace the entire pollSessions function (lines 239–255) with:
async function pollSessions() {
// If no sources configured yet, fall back to local-only
var sources = _sources.length > 0 ? _sources : [{ url: '', name: 'This device', type: 'local', status: 'authenticated', backoffMs: 2000 }];
// Fetch each source in parallel
var promises = sources.map(function(source) {
// Skip sources that are in backoff (unreachable with nextRetryAt in the future)
if (source.nextRetryAt && Date.now() < source.nextRetryAt) {
return Promise.resolve(null);
}
return api('GET', '/api/sessions', undefined, source.url || undefined)
.then(function(res) { return res.json(); })
.then(function(sessions) {
source.status = 'authenticated';
source.backoffMs = 2000;
delete source.nextRetryAt;
return { source: source, sessions: sessions };
})
.catch(function(err) {
// Check if it's a 401/403
if (err.message && (err.message.includes('401') || err.message.includes('403'))) {
source.status = 'auth_required';
} else {
source.status = 'unreachable';
// Exponential backoff: 2s → 4s → 8s → cap at 30s
source.nextRetryAt = Date.now() + source.backoffMs;
source.backoffMs = Math.min(source.backoffMs * 2, 30000);
}
return null;
});
});
var results = await Promise.all(promises);
var successful = results.filter(function(r) { return r !== null; });
var merged = mergeSources(successful);
var prev = _currentSessions;
_currentSessions = merged;
// Connection status based on local source health
var localSource = sources.find(function(s) { return s.type === 'local'; });
if (localSource && localSource.status === 'authenticated') {
_pollFailCount = 0;
setConnectionStatus('ok');
} else {
_pollFailCount++;
setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err');
}
renderGrid(merged);
renderSidebar(merged, _viewingSession);
handleBellTransitions(prev, merged);
updateSessionPill(merged);
}
Step 4: Run tests to verify they pass
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass (including existing pollSessions tests which still work because _sources defaults to empty so local-only fallback triggers).
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: rewrite pollSessions for multi-source parallel polling"
Task 6: Wire sources initialization into startup
Files:
- Modify:
muxplex/frontend/app.js
No new test for this task — it wires existing tested functions into the DOMContentLoaded handler.
Step 1: Modify loadServerSettings to rebuild sources
In muxplex/frontend/app.js, find the loadServerSettings function (around line 954). Replace it with:
async function loadServerSettings() {
try {
const res = await api('GET', '/api/settings');
_serverSettings = await res.json();
} catch (err) {
console.warn('[loadServerSettings] failed:', err);
if (!_serverSettings) _serverSettings = {};
}
// Rebuild sources from settings
_sources = buildSources(_serverSettings);
return _serverSettings;
}
Step 2: Run all tests
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 3: Commit
cd muxplex && git add muxplex/frontend/app.js && git commit -m "feat: rebuild sources list when server settings load"
Task 7: Update getVisibleSessions for session keys
Files:
- Modify:
muxplex/frontend/app.js - Test:
muxplex/frontend/tests/test_app.mjs
The getVisibleSessions function currently filters by s.name — but with federation, two sessions from different devices can share a name. We need it to only hide LOCAL sessions matching the hidden list (remote sessions with the same name should still show).
Step 1: Write the failing test
Add to end of test_app.mjs:
// --- getVisibleSessions with federation ---
test('getVisibleSessions exported and filters hidden sessions', () => {
assert.strictEqual(typeof app.getVisibleSessions, 'function');
});
test('getVisibleSessions hides local sessions by name but not remote sessions with same name', () => {
app._setServerSettings({ hidden_sessions: ['main'] });
const sessions = [
{ name: 'main', sourceUrl: '', sessionKey: '::main' },
{ name: 'main', sourceUrl: 'http://work:8088', sessionKey: 'http://work:8088::main' },
{ name: 'dev', sourceUrl: '', sessionKey: '::dev' },
];
const visible = app.getVisibleSessions(sessions);
assert.strictEqual(visible.length, 2, 'should hide local main but keep remote main');
assert.ok(visible.some(function(s) { return s.sessionKey === 'http://work:8088::main'; }));
assert.ok(visible.some(function(s) { return s.sessionKey === '::dev'; }));
app._setServerSettings(null);
});
Step 2: Run test to verify it fails
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
Expected: FAIL — current getVisibleSessions would hide BOTH "main" sessions.
Step 3: Update getVisibleSessions
In muxplex/frontend/app.js, replace the getVisibleSessions function (around line 459) with:
function getVisibleSessions(sessions) {
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
return (sessions || []).filter(function(s) {
// hidden_sessions only applies to local sessions (sourceUrl is empty or absent)
if (hidden.length > 0 && (!s.sourceUrl) && hidden.includes(s.name)) {
return false;
}
return true;
});
}
Add getVisibleSessions to module.exports (it's not currently exported):
getVisibleSessions,
Step 4: Run tests to verify they pass
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: getVisibleSessions only hides local sessions, not remote with same name"
Task 8: CSS for device badge, group header, filter bar
Files:
- Modify:
muxplex/frontend/style.css
No TDD step for CSS — add the styles needed for Tasks 9–13.
Step 1: Add new CSS sections
In muxplex/frontend/style.css, add this new section BEFORE the final responsive overlay sidebar block (before line 1350 which starts /* ============================================================ Responsive overlay sidebar):
/* ============================================================
Device badge (on tiles and sidebar items)
============================================================ */
.device-badge {
display: inline-block;
font-size: 9px;
font-weight: 600;
color: var(--accent);
background: var(--accent-dim);
border-radius: 3px;
padding: 1px 5px;
margin-left: 6px;
white-space: nowrap;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
line-height: 14px;
}
/* ============================================================
Device group header (grouped view mode)
============================================================ */
.device-group-header {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
padding: 12px 0 4px 0;
margin: 0;
grid-column: 1 / -1;
border-bottom: 1px solid var(--border-subtle);
}
.device-group-header:first-child {
padding-top: 0;
}
/* ============================================================
Filter bar (filtered view mode)
============================================================ */
.filter-bar {
display: flex;
gap: 6px;
padding: 0 var(--grid-padding) 8px;
flex-shrink: 0;
flex-wrap: wrap;
}
.filter-pill {
font-size: 12px;
font-family: var(--font-ui);
padding: 4px 12px;
border-radius: 14px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
white-space: nowrap;
}
.filter-pill:hover {
border-color: var(--accent);
color: var(--text);
}
.filter-pill--active {
border-color: var(--accent);
background: var(--accent-dim);
color: var(--accent);
font-weight: 600;
}
/* ============================================================
Sidebar device group header
============================================================ */
.sidebar-device-header {
font-size: 10px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 10px 12px 4px;
margin: 0;
}
.sidebar-device-header:first-child {
padding-top: 6px;
}
Step 2: Verify CSS parses correctly
cd muxplex && python3 -c "
css = open('muxplex/frontend/style.css').read()
for cls in ['.device-badge', '.device-group-header', '.filter-bar', '.filter-pill', '.filter-pill--active', '.sidebar-device-header']:
assert cls in css, f'Missing {cls}'
print('All new CSS classes present')
"
Expected: All new CSS classes present
Step 3: Commit
cd muxplex && git add muxplex/frontend/style.css && git commit -m "feat: add CSS for device badge, group headers, and filter bar"
Task 9: Device badge on tiles
Files:
- Modify:
muxplex/frontend/app.js(buildTileHTML) - Test:
muxplex/frontend/tests/test_app.mjs
Step 1: Write the failing tests
Add to end of test_app.mjs:
// --- buildTileHTML device badge ---
test('buildTileHTML shows device-badge when session has deviceName and multiple sources', () => {
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: 'Laptop', sourceUrl: '', snapshot: '' };
const html = app.buildTileHTML(session, 0, false);
assert.ok(html.includes('device-badge'), 'should include device-badge span');
assert.ok(html.includes('Laptop'), 'badge should show device name');
app._setSources([]);
});
test('buildTileHTML omits device-badge when only one source configured', () => {
app._setSources([
{ url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 },
]);
const session = { name: 'main', deviceName: 'Laptop', sourceUrl: '', snapshot: '' };
const html = app.buildTileHTML(session, 0, false);
assert.ok(!html.includes('device-badge'), 'should NOT include device-badge with single source');
app._setSources([]);
});
Step 2: Run tests to verify they fail
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
Expected: FAIL — current buildTileHTML doesn't produce device-badge.
Step 3: Update buildTileHTML
In muxplex/frontend/app.js, find the buildTileHTML function (around line 378). Replace the tile header line inside the return statement. Find:
`<div class="tile-header">` +
`<span class="tile-name">${escapeHtml(name)}</span>` +
`<span class="tile-meta">${bellHtml}<span class="tile-time">${escapeHtml(timeStr)}</span></span>` +
`</div>` +
Replace with:
`<div class="tile-header">` +
`<span class="tile-name">${escapeHtml(name)}${_sources.length > 1 && session.deviceName ? '<span class="device-badge">' + escapeHtml(session.deviceName) + '</span>' : ''}</span>` +
`<span class="tile-meta">${bellHtml}<span class="tile-time">${escapeHtml(timeStr)}</span></span>` +
`</div>` +
Also update the data-session attribute in the tile to use sessionKey when available (for unique identification). Find:
`<article class="${classes}" data-session="${escapedName}" tabindex="0" role="listitem" aria-label="${escapedName}">` +
Replace with:
`<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}" data-source-url="${escapeHtml(session.sourceUrl || '')}" tabindex="0" role="listitem" aria-label="${escapedName}">` +
Step 4: Run tests to verify they pass
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
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:
// --- 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
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:
`<span class="sidebar-item-name">${escapedName}</span>` +
Replace with:
`<span class="sidebar-item-name">${escapedName}${_sources.length > 1 && session.deviceName ? '<span class="device-badge">' + escapeHtml(session.deviceName) + '</span>' : ''}</span>` +
Also update the data attribute for session key. Find:
`<article class="${classes}" data-session="${escapedName}" tabindex="0" role="listitem">` +
Replace with:
`<article class="${classes}" data-session="${escapedName}" data-source-url="${escapeHtml(session.sourceUrl || '')}" tabindex="0" role="listitem">` +
Step 4: Run tests to verify they pass
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
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:
// --- 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
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:
/** Test-only: set _gridViewMode directly. */
function _setGridViewMode(mode) {
_gridViewMode = mode;
}
Add _setGridViewMode to module.exports.
Now update renderGrid. Replace the entire renderGrid function with:
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:
/**
* 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 += '<h3 class="device-group-header">' + escapeHtml(name) + '</h3>';
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
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
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:
<div id="session-grid" class="session-grid" role="list"></div>
Add the filter bar container BEFORE it:
<div id="filter-bar" class="filter-bar"></div>
<div id="session-grid" class="session-grid" role="list"></div>
Step 2: Write the failing test
Add to end of test_app.mjs:
// --- 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
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:
/**
* 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 = '<button class="filter-pill' + (_activeFilterDevice === 'all' ? ' filter-pill--active' : '') + '" data-filter-device="all">All</button>';
for (var d = 0; d < devices.length; d++) {
var active = _activeFilterDevice === devices[d] ? ' filter-pill--active' : '';
html += '<button class="filter-pill' + active + '" data-filter-device="' + escapeHtml(devices[d]) + '">' + escapeHtml(devices[d]) + '</button>';
}
container.innerHTML = html;
}
Add a test-only setter for _activeFilterDevice:
/** Test-only: set _activeFilterDevice directly. */
function _setActiveFilterDevice(device) {
_activeFilterDevice = device;
}
Add to module.exports:
renderFilterBar,
_setActiveFilterDevice,
renderGroupedGrid,
Step 5: Run tests to verify they pass
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 }):
// 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
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:
// --- 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
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:
/**
* 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:
_gridViewMode = loadGridViewMode();
Add to module.exports:
loadGridViewMode,
saveGridViewMode,
Step 4: Run tests to verify they pass
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
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 </div> that closes data-tab="display", around line 123). Add BEFORE that closing </div>:
<div class="settings-field">
<label class="settings-label" for="setting-view-mode">Grid View Mode</label>
<select id="setting-view-mode" class="settings-select">
<option value="flat">Flat</option>
<option value="grouped">Grouped by device</option>
<option value="filtered">Filtered by device</option>
</select>
</div>
<div class="settings-field">
<label class="settings-label" for="setting-view-scope">View Preference Scope</label>
<select id="setting-view-scope" class="settings-select">
<option value="local">This browser only</option>
<option value="server">Sync across browsers</option>
</select>
</div>
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:
// 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:
// 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
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
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 </div> of that panel:
<div class="settings-field settings-field--column">
<label class="settings-label">Device Name</label>
<input type="text" id="setting-device-name" class="settings-input" placeholder="e.g. Laptop" />
<span class="settings-helper">How this instance appears on other dashboards</span>
</div>
<div class="settings-field settings-field--column">
<label class="settings-label">Remote Instances</label>
<div id="setting-remote-instances" class="settings-remote-list"></div>
<button id="add-remote-instance-btn" class="settings-action-btn">+ Add Instance</button>
</div>
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:
// 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 =
'<input type="text" class="settings-input settings-remote-url" value="' + escapeHtml(inst.url || '') + '" placeholder="http://host:8088" />' +
'<input type="text" class="settings-input settings-remote-name" value="' + escapeHtml(inst.name || '') + '" placeholder="Name" />' +
'<button class="settings-remote-remove" aria-label="Remove">×</button>';
remoteEl.appendChild(row);
}
}
Step 3: Bind change handlers in bindStaticEventListeners
In bindStaticEventListeners, add these blocks:
// 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 =
'<input type="text" class="settings-input settings-remote-url" value="" placeholder="http://host:8088" />' +
'<input type="text" class="settings-input settings-remote-name" value="" placeholder="Name" />' +
'<button class="settings-remote-remove" aria-label="Remove">×</button>';
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:
/**
* 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:
/* ============================================================
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
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 6: Commit
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:
// --- 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
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:
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 = '<div class="sidebar-empty">No sessions</div>';
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 += '<h4 class="sidebar-device-header">' + escapeHtml(groupOrder[g]) + '</h4>';
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
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5
Expected: All tests pass.
Step 5: Commit
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:
// 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:
// 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:
// Mount terminal NOW — /connect has completed, new ttyd is serving the correct session
if (window._openTerminal) window._openTerminal(name);
Replace with:
// 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
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
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:
// --- 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
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:
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
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
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:
// --- 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
cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10
Expected: All tests pass.
Step 3: Commit
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.