# 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/sessions` → `renderGrid` → `renderSidebar` → `handleBellTransitions` → `updateSessionPill` 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 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): ```javascript // --- 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** ```bash 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: ```javascript 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: ```javascript // Fetch wrapper api, pollSessions, ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 ``` Expected: All tests pass. **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: 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`: ```javascript // --- 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** ```bash 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): ```javascript /** * 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): ```javascript let _sources = []; ``` Add `buildSources` and `_sources` to the `module.exports` block. Find the `api,` line you added in Task 1 and add after it: ```javascript buildSources, ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 ``` Expected: All tests pass. **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add 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`: ```javascript // --- 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** ```bash 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): ```javascript /** 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): ```javascript let _gridViewMode = 'flat'; let _activeFilterDevice = 'all'; ``` Add to `module.exports`: ```javascript _setSources, _setServerSettings, _getGridViewMode, _getSources, ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 ``` Expected: All tests pass. **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add 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`: ```javascript // --- 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** ```bash 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`: ```javascript /** * 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`: ```javascript tagSessions, mergeSources, ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 ``` Expected: All tests pass. **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add 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`: ```javascript // --- 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** ```bash 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: ```javascript 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** ```bash 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** ```bash 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: ```javascript 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** ```bash cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 ``` Expected: All tests pass. **Step 3: Commit** ```bash 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`: ```javascript // --- 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** ```bash 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: ```javascript 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): ```javascript getVisibleSessions, ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 ``` Expected: All tests pass. **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: 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`): ```css /* ============================================================ 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** ```bash 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** ```bash 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`: ```javascript // --- 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** ```bash 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: ```javascript `
` + `${escapeHtml(name)}` + `${bellHtml}${escapeHtml(timeStr)}` + `
` + ``` Replace with: ```javascript `
` + `${escapeHtml(name)}${_sources.length > 1 && session.deviceName ? '' + escapeHtml(session.deviceName) + '' : ''}` + `${bellHtml}${escapeHtml(timeStr)}` + `
` + ``` Also update the `data-session` attribute in the tile to use `sessionKey` when available (for unique identification). Find: ```javascript `
` + ``` Replace with: ```javascript `
` + ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 ``` Expected: All tests pass. **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device badge to session tiles when multiple sources configured" ``` --- ## Task 10: Device badge on sidebar items **Files:** - Modify: `muxplex/frontend/app.js` (`buildSidebarHTML`) - Test: `muxplex/frontend/tests/test_app.mjs` **Step 1: Write the failing test** Add to end of `test_app.mjs`: ```javascript // --- buildSidebarHTML device badge --- test('buildSidebarHTML shows device-badge when multiple sources configured', () => { app._setSources([ { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, { url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 }, ]); const session = { name: 'main', deviceName: 'Work', sourceUrl: 'http://work:8088', snapshot: '' }; const html = app.buildSidebarHTML(session, null); assert.ok(html.includes('device-badge'), 'should include device-badge'); assert.ok(html.includes('Work'), 'badge should show Work'); app._setSources([]); }); ``` **Step 2: Run test to verify it fails** ```bash cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 ``` Expected: FAIL — `buildSidebarHTML` doesn't produce `device-badge`. **Step 3: Update `buildSidebarHTML`** In `muxplex/frontend/app.js`, find the `buildSidebarHTML` function (around line 420). Find the sidebar name span: ```javascript `${escapedName}` + ``` Replace with: ```javascript `${escapedName}${_sources.length > 1 && session.deviceName ? '' + escapeHtml(session.deviceName) + '' : ''}` + ``` Also update the data attribute for session key. Find: ```javascript `
` + ``` Replace with: ```javascript `
` + ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 ``` Expected: All tests pass. **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device badge to sidebar items when multiple sources configured" ``` --- ## Task 11: Grouped view mode rendering **Files:** - Modify: `muxplex/frontend/app.js` (`renderGrid`) - Test: `muxplex/frontend/tests/test_app.mjs` **Step 1: Write the failing test** Add to end of `test_app.mjs`: ```javascript // --- renderGrid grouped view mode --- test('renderGrid in grouped mode produces device-group-header elements', () => { const mockGrid = { innerHTML: '' }; const mockEmpty = { classList: { add: () => {}, remove: () => {} } }; const origGetById = globalThis.document.getElementById; const origQSA = globalThis.document.querySelectorAll; globalThis.document.getElementById = (id) => { if (id === 'session-grid') return mockGrid; if (id === 'empty-state') return mockEmpty; if (id === 'filter-bar') return null; return null; }; globalThis.document.querySelectorAll = () => []; app._setSources([ { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, { url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 }, ]); app._setServerSettings({}); // Temporarily set grid view mode app._setGridViewMode('grouped'); const sessions = [ { name: 'sess-a', deviceName: 'Laptop', sourceUrl: '', snapshot: '', sessionKey: '::sess-a' }, { name: 'sess-b', deviceName: 'Work', sourceUrl: 'http://work:8088', snapshot: '', sessionKey: 'http://work:8088::sess-b' }, ]; app.renderGrid(sessions); assert.ok(mockGrid.innerHTML.includes('device-group-header'), 'grouped mode should include device-group-header'); assert.ok(mockGrid.innerHTML.includes('Laptop'), 'should show Laptop header'); assert.ok(mockGrid.innerHTML.includes('Work'), 'should show Work header'); app._setGridViewMode('flat'); app._setSources([]); globalThis.document.getElementById = origGetById; globalThis.document.querySelectorAll = origQSA; }); ``` **Step 2: Run test to verify it fails** ```bash cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 ``` Expected: FAIL — `app._setGridViewMode is not a function` (or no device-group-header in output). **Step 3: Implement** First, add a test-only setter for `_gridViewMode`. In app.js, after `_getGridViewMode`: ```javascript /** Test-only: set _gridViewMode directly. */ function _setGridViewMode(mode) { _gridViewMode = mode; } ``` Add `_setGridViewMode` to `module.exports`. Now update `renderGrid`. Replace the entire `renderGrid` function with: ```javascript function renderGrid(sessions) { var grid = $('session-grid'); var emptyState = $('empty-state'); var filterBar = $('filter-bar'); var visible = getVisibleSessions(sessions); // In filtered mode, apply device filter if (_gridViewMode === 'filtered' && _activeFilterDevice !== 'all') { visible = visible.filter(function(s) { return s.deviceName === _activeFilterDevice; }); } if (visible.length === 0) { if (grid) grid.innerHTML = ''; if (emptyState) emptyState.classList.remove('hidden'); // Show filter bar even when filtered to empty (so user can switch back) if (filterBar) { if (_gridViewMode === 'filtered') { renderFilterBar(filterBar, sessions); } else { filterBar.innerHTML = ''; } } return; } if (emptyState) emptyState.classList.add('hidden'); // Apply sort order from server settings var sortOrder = _serverSettings && _serverSettings.sort_order; var mobile = isMobile(); var ordered; if (sortOrder === 'alphabetical') { ordered = visible.slice().sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); }); } else { ordered = mobile ? sortByPriority(visible) : visible; } var html; if (_gridViewMode === 'grouped') { html = renderGroupedGrid(ordered, mobile); } else { html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join(''); } if (grid) grid.innerHTML = html; // Render filter bar if (filterBar) { if (_gridViewMode === 'filtered') { renderFilterBar(filterBar, sessions); } else { filterBar.innerHTML = ''; } } // Bind interaction handlers on each tile document.querySelectorAll('.session-tile').forEach(function(tile) { on(tile, 'click', function() { openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl }); }); on(tile, 'keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl }); } }); }); if (_viewMode === 'fullscreen') { updatePillBell(); } } ``` Add the `renderGroupedGrid` helper right before `renderGrid`: ```javascript /** * Render sessions grouped by device name. Returns HTML string. * @param {object[]} sessions - sorted, visible sessions * @param {boolean} mobile * @returns {string} */ function renderGroupedGrid(sessions, mobile) { // Group by deviceName var groups = {}; var groupOrder = []; for (var i = 0; i < sessions.length; i++) { var dn = sessions[i].deviceName || 'Unknown'; if (!groups[dn]) { groups[dn] = []; groupOrder.push(dn); } groups[dn].push(sessions[i]); } var html = ''; for (var g = 0; g < groupOrder.length; g++) { var name = groupOrder[g]; html += '

' + escapeHtml(name) + '

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