Files
muxplex/docs/plans/2026-03-30-multi-device-federation-phase2.md
2026-03-31 03:40:57 -07:00

70 KiB
Raw Permalink Blame History

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.py has remote_instances (list of {url, name}) and device_name (string) in DEFAULT_SETTINGS
  • GET /api/instance-info route exists — no auth required, returns {"name": "DeviceName", "version": "0.1.0"}
  • CORS middleware is active on all instances (allows any origin)
  • /api/instance-info is 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

  1. No framework, no bundler — plain functions, no classes, no imports
  2. DOM helpers: $(id)document.getElementById(id), on(el, ev, fn)
  3. API wrapper: async function api(method, path, body) — line 160, currently uses relative URLs
  4. State globals: _deviceId, _currentSessions, _viewingSession, _viewMode ('grid'|'fullscreen'), _serverSettings, _pollFailCount
  5. Poll pattern: pollSessions()GET /api/sessionsrenderGridrenderSidebarhandleBellTransitionsupdateSessionPill
  6. Settings: loadServerSettings()GET /api/settings → caches in _serverSettings. patchServerSetting(key, value)PATCH /api/settings
  7. Display settings: loadDisplaySettings() / saveDisplaySettings() use localStorage key muxplex.display
  8. 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
  9. Test-only helpers: _setCurrentSessions(sessions), _setViewMode(mode), _setViewingSession(name) — for setting internal state from tests

Key patterns in test_app.mjs you MUST follow

  1. Import: const app = require(join(__dirname, '..', 'app.js'));
  2. Stub globals BEFORE import at top of file — globalThis.localStorage, globalThis.document, globalThis.window, globalThis.fetch
  3. Per-test mocking pattern: Save original → replace → run test → restore original
  4. Flat test() calls (not nested describe/it) for most tests
  5. assert.* from node:assert/strict
  6. Run command: cd muxplex && node --test muxplex/frontend/tests/test_app.mjs

Key patterns in style.css

  1. CSS custom properties on :root: --bg, --accent, --bell, --tile-height, --tile-min-width, etc.
  2. Sections delimited by /* ============================================================ */ comments
  3. Grid: .session-grid uses repeat(auto-fill, minmax(var(--tile-min-width), 1fr))
  4. 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) calls window._openTerminal(name) — do NOT break this for local sessions
  • getVisibleSessions() filters by hidden_sessions server setting — hidden filtering must still work after session tagging
  • pollSessions() 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 159171)
  • 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 160171) 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 (the pollSessions function)
  • 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 239255) 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 913.

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">&times;</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">&times;</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.