diff --git a/docs/plans/2026-04-04-device-selector.md b/docs/plans/2026-04-04-device-selector.md new file mode 100644 index 0000000..e4dac2d --- /dev/null +++ b/docs/plans/2026-04-04-device-selector.md @@ -0,0 +1,751 @@ +# Device Selector for New Session Dialog — Implementation Plan + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** When multi-device is enabled with remote instances configured, the "new session" dialog shows a device dropdown so users can create sessions on any device — local or remote. + +**Architecture:** Add a `POST /api/federation/{remote_id}/sessions` proxy endpoint on the backend (same pattern as existing `federation_connect` and `federation_bell_clear`). On the frontend, inject a `` helper and wire into `showNewSessionInput()` + +**Files:** +- Modify: `muxplex/frontend/app.js:1902-1945` (the `_createSessionInput` and `showNewSessionInput` functions) +- Test: `muxplex/frontend/tests/test_app.mjs` (append) + +**Step 1: Write the failing tests** + +Append these tests to the end of `muxplex/frontend/tests/test_app.mjs`: + +```javascript + +// --- Device selector in new session dialog --- + +test('_createDeviceSelect builds a element'); + assert.ok(snippet.includes('remote_instances'), '_createDeviceSelect must read remote_instances from settings'); + assert.ok(snippet.includes('device_name'), '_createDeviceSelect must use device_name for local option label'); +}); + +test('showNewSessionInput creates device select when multi_device_enabled with remotes', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const start = source.indexOf('function showNewSessionInput('); + assert.ok(start !== -1, 'showNewSessionInput must exist'); + const snippet = source.slice(start, start + 1200); + assert.ok(snippet.includes('_createDeviceSelect'), 'showNewSessionInput must call _createDeviceSelect'); +}); + +test('showNewSessionInput passes remoteId from device select to createNewSession', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const start = source.indexOf('function showNewSessionInput('); + const snippet = source.slice(start, start + 1200); + // The Enter handler must read select.value and pass it to createNewSession + assert.ok( + snippet.includes('createNewSession(name,') || snippet.includes('createNewSession(name ,'), + 'showNewSessionInput must pass a second argument (remoteId) to createNewSession' + ); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20` + +Expected: The 3 new tests FAIL + +**Step 3: Implement the device select helper and update `showNewSessionInput`** + +In `muxplex/frontend/app.js`, insert a new `_createDeviceSelect()` function immediately **after** the `_createSessionInput()` function (after line 1910, before line 1912): + +```javascript + +/** + * Create a device ` to `showFabSessionInput()` (mobile) + +**Files:** +- Modify: `muxplex/frontend/app.js:1953-1987` (the `showFabSessionInput` function) +- Test: `muxplex/frontend/tests/test_app.mjs` (append) + +**Step 1: Write the failing test** + +Append to `muxplex/frontend/tests/test_app.mjs`: + +```javascript + +test('showFabSessionInput creates device select when multi_device_enabled with remotes', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const start = source.indexOf('function showFabSessionInput('); + assert.ok(start !== -1, 'showFabSessionInput must exist'); + const snippet = source.slice(start, start + 1200); + assert.ok(snippet.includes('_createDeviceSelect'), 'showFabSessionInput must call _createDeviceSelect'); + assert.ok( + snippet.includes('createNewSession(name,') || snippet.includes('createNewSession(name ,'), + 'showFabSessionInput must pass remoteId to createNewSession' + ); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10` + +Expected: New test FAILS + +**Step 3: Update `showFabSessionInput` to include device select** + +Replace the entire `showFabSessionInput` function (lines 1953–1987, note: line numbers may have shifted after Task 2 edits) with: + +```javascript +function showFabSessionInput() { + if (document.querySelector('.fab-input-overlay')) return; + + var fab = $('new-session-fab'); + + var overlay = document.createElement('div'); + overlay.className = 'fab-input-overlay'; + + var select = _createDeviceSelect(); + var input = _createSessionInput(); + + if (select) overlay.appendChild(select); + overlay.appendChild(input); + + function cleanup() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (fab) fab.style.display = ''; + } + + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + var name = input.value.trim(); + var remoteId = select ? select.value : ''; + cleanup(); + if (name) createNewSession(name, remoteId); + } else if (e.key === 'Escape') { + cleanup(); + } + }); + + input.addEventListener('blur', function() { + setTimeout(cleanup, 150); + }); + + if (fab) fab.style.display = 'none'; + document.body.appendChild(overlay); + input.focus(); +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10` + +Expected: New test PASSES, no regressions + +**Step 5: Commit** + +`cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device select to mobile FAB session input"` + +--- + +### Task 4: Frontend — Update `createNewSession()` to route through federation proxy + +**Files:** +- Modify: `muxplex/frontend/app.js:1998-2054` (the `createNewSession` function) +- Test: `muxplex/frontend/tests/test_app.mjs` (append) + +**Step 1: Write the failing tests** + +Append to `muxplex/frontend/tests/test_app.mjs`: + +```javascript + +test('createNewSession accepts remoteId parameter and routes to federation endpoint', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const start = source.indexOf('async function createNewSession('); + assert.ok(start !== -1, 'createNewSession must exist'); + const snippet = source.slice(start, start + 3000); + + // Function signature must accept remoteId + assert.ok( + snippet.startsWith("async function createNewSession(name, remoteId") || + snippet.startsWith("async function createNewSession(name,remoteId"), + 'createNewSession must accept a remoteId parameter' + ); + + // Must route to federation endpoint when remoteId is set + assert.ok( + snippet.includes('/api/federation/'), + 'createNewSession must POST to /api/federation/ endpoint when remoteId is set' + ); + + // Must still support local endpoint + assert.ok( + snippet.includes("'/api/sessions'"), + 'createNewSession must still POST to /api/sessions for local sessions' + ); +}); + +test('createNewSession passes remoteId through to openSession for auto-open', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const start = source.indexOf('async function createNewSession('); + const snippet = source.slice(start, start + 3000); + + // Must pass remoteId to openSession in the poll callback + assert.ok( + snippet.includes('openSession(sessionName, { remoteId') || + snippet.includes('openSession(sessionName, {remoteId'), + 'createNewSession must pass { remoteId } opts to openSession when auto-opening' + ); +}); + +test('createNewSession matches remote sessions by sessionKey in poll loop', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const start = source.indexOf('async function createNewSession('); + const snippet = source.slice(start, start + 3000); + + // When remoteId is set, must match by sessionKey (remoteId:name) not just name + assert.ok( + snippet.includes('sessionKey'), + 'createNewSession must match remote sessions by sessionKey in the polling loop' + ); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20` + +Expected: The 3 new tests FAIL + +**Step 3: Update `createNewSession` to accept and use `remoteId`** + +Replace the entire `createNewSession` function (from `async function createNewSession(name)` through its closing `}`) with: + +```javascript +async function createNewSession(name, remoteId) { + remoteId = remoteId || ''; + try { + var endpoint = remoteId + ? '/api/federation/' + encodeURIComponent(remoteId) + '/sessions' + : '/api/sessions'; + const res = await api('POST', endpoint, { name }); + const data = await res.json(); + const sessionName = data.name || name; + showToast('Creating session \'' + sessionName + '\'\u2026'); + + // Inject a loading placeholder tile so the user sees feedback immediately + var loadingTile = null; + var grid = document.getElementById('session-grid'); + if (grid) { + loadingTile = document.createElement('div'); + loadingTile.className = 'session-tile tile--loading'; + loadingTile.id = 'loading-tile-' + sessionName; + loadingTile.innerHTML = + '
' + escapeHtml(sessionName) + '' + + 'Creating...
' + + '
'; + grid.appendChild(loadingTile); + } + + function removeLoadingTile() { + var tile = document.getElementById('loading-tile-' + sessionName); + if (tile) tile.remove(); + } + + const ss = _serverSettings || {}; + if (ss.auto_open_created === false) { + // Auto-open disabled — just do one refresh + await pollSessions(); + removeLoadingTile(); + return; + } + + // For remote sessions, the sessionKey is "remoteId:name" + var expectedKey = remoteId ? (remoteId + ':' + sessionName) : sessionName; + + // Poll until the session appears in _currentSessions (max 30s, every 2s) + var attempts = 0; + var maxAttempts = 15; + var pollForSession = setInterval(async function() { + attempts++; + await pollSessions(); + var found = _currentSessions && _currentSessions.find(function(s) { + return (s.sessionKey || s.name) === expectedKey; + }); + if (found) { + clearInterval(pollForSession); + removeLoadingTile(); + showToast('Session \'' + sessionName + '\' ready'); + openSession(sessionName, { remoteId: remoteId }); + } else if (attempts >= maxAttempts) { + clearInterval(pollForSession); + removeLoadingTile(); + showToast('Session \'' + sessionName + '\' is taking longer than expected'); + } + }, 2000); + } catch (err) { + showToast(err.message || 'Failed to create session'); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20` + +Expected: All new tests PASS, no regressions + +**Step 5: Commit** + +`cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: route createNewSession through federation proxy for remote devices"` + +--- + +### Task 5: CSS — Style the device select dropdown + +**Files:** +- Modify: `muxplex/frontend/style.css` (after `.new-session-input::placeholder` rule, ~line 1523) +- Test: `muxplex/frontend/tests/test_app.mjs` (append) + +**Step 1: Write the failing test** + +Append to `muxplex/frontend/tests/test_app.mjs`: + +```javascript + +test('CSS has new-session-device-select styling', () => { + const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + assert.ok(source.includes('.new-session-device-select'), 'style.css must have .new-session-device-select rule'); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5` + +Expected: FAIL — CSS rule doesn't exist yet + +**Step 3: Add the CSS rule** + +In `muxplex/frontend/style.css`, insert immediately after the `.new-session-input::placeholder` rule (after line 1523): + +```css + +.new-session-device-select { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 12px; + font-family: var(--font-ui); + padding: 4px 6px; + color: var(--text); + outline: none; + margin-right: 6px; + vertical-align: middle; +} + +.fab-input-overlay .new-session-device-select { + display: block; + width: 100%; + margin-bottom: 6px; + margin-right: 0; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5` + +Expected: PASS + +**Step 5: Commit** + +`cd muxplex && git add muxplex/frontend/style.css muxplex/frontend/tests/test_app.mjs && git commit -m "feat: style device select dropdown for new session dialog"` + +--- + +### Task 6: Full test suite verification + +**Files:** +- No new files — verification only + +**Step 1: Run the full backend test suite** + +Run: `cd muxplex && python -m pytest muxplex/tests/test_api.py -v --tb=short 2>&1 | tail -30` + +Expected: All tests PASS (including the 4 new federation_create_session tests) + +**Step 2: Run the full frontend test suite** + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -30` + +Expected: All tests PASS (including the 5 new device-selector tests) + +**Step 3: Run Python quality checks** + +Run: `cd muxplex && python -m ruff check muxplex/main.py muxplex/tests/test_api.py && python -m ruff format --check muxplex/main.py muxplex/tests/test_api.py` + +Expected: No issues + +**Step 4: Commit (if any lint/format fixes were needed)** + +`cd muxplex && git add -A && git commit -m "chore: lint/format fixes" --allow-empty`