- Change serve() signature: all params now default to None (sentinel for 'not passed by CLI')
- Add load_settings() call: resolution order is CLI flag > settings.json > hardcoded default
- Switch from os.environ.setdefault() to os.environ[] hard assignment so settings.json values take effect
- Add 6 new tests: host/port/session_ttl from settings, CLI override, fallback to defaults, session_ttl=0 as valid value
Add four new server configuration keys to DEFAULT_SETTINGS in settings.py:
- host: '127.0.0.1'
- port: 8088
- auth: 'pam'
- session_ttl: 604800
Serve keys are placed first in the dict as primary server configuration.
Existing keys (default_session, sort_order, etc.) follow unchanged.
Add four tests covering the new keys:
- test_default_settings_include_serve_keys
- test_load_settings_returns_serve_keys_when_file_missing
- test_serve_keys_patchable
- test_old_settings_file_without_serve_keys_loads_correctly
- Config file (settings.json) becomes source of truth for serve options
- Replace install-service with muxplex service <command> subgroup
- Clean up CLI structure (upgrade/update alias, serve flag precedence)
- Thin wrappers over systemctl/launchctl for install/uninstall/start/stop/restart/status/logs
- Backward compat: install-service remains as deprecated alias
Root cause of repeated fit view failures: applyFitLayout() measured
clientHeight/clientWidth/getComputedStyle and set inline tile heights. This
failed in multiple ways:
- clientHeight = 0 when container is display:none (returning from session)
- inline tile.style.height destroyed every 2s by innerHTML rebuild in pollSessions
- getComputedStyle forces style recalc with potentially stale values
- rAF wrappers couldn't reliably fix timing issues
Fix: pure arithmetic applyFitLayout — no DOM measurement, no inline heights.
The grid already has a definite height from CSS (flex:1 inside height:100dvh).
grid-template-rows: repeat(rows, 1fr) divides that space without JS measurement.
CSS changes:
- .session-grid--fit { align-content: stretch }
- .session-grid--fit .session-tile { height: auto } — CSS grid cell controls sizing
JS changes:
- applyFitLayout() removes all clientHeight/clientWidth/getComputedStyle/style.height
- applyFitLayout() is now pure arithmetic: count tiles, compute cols×rows, set 1fr templates
- Removed rAF wrappers from all call sites (renderGrid, closeSession, resize handler,
applyDisplaySettings) — safe to call synchronously since no layout measurement needed
- applyDisplaySettings() no longer clears tile heights (never set them anymore)
Tests updated:
- Added test_fit_view_session_tile_has_height_auto (CSS)
- Added 'applyFitLayout does NOT measure DOM dimensions' (JS)
- Updated 'applyFitLayout clears stale...' → 'sets gridTemplateColumns/Rows via arithmetic'
- Updated rAF-wrapper test to match new direct-call behavior
kill_ttyd() now uses two strategies:
1. PID file (existing behavior — kept intact)
2. Port-based fallback: lsof -ti :{TTYD_PORT} finds and kills any orphaned
ttyd process that wasn't tracked in the PID file
spawn_ttyd() also does a pre-spawn port-free guard: if any process is still
occupying TTYD_PORT after kill_ttyd() returns, it sends SIGKILL to force-free
the port before the new ttyd tries to bind.
Root cause: PID file became desynced (pointed to dead process). kill_ttyd()
thought it succeeded but the REAL ttyd (PID not in file) kept running on
port 7682. New spawn_ttyd() failed to bind, died silently. Old ttyd kept
serving the old session — so every session switch showed the same session.
Tests: 2 new tests in test_ttyd.py (RED → GREEN confirmed)
Bug 1: applyFitLayout now clears grid-template-rows and tile heights
before recalculating. Prevents stale layout from empty-grid calls on
page reload from interfering with subsequent calculations.
Bug 2: Removed flex justify-content:flex-end approach — didn't work
because pre filled 100% of parent (flex-end had no effect). Reverted
to base CSS position:absolute + bottom:0 which anchors content to the
bottom. With 80 lines in fit mode, content always overflows the tile
and excess is clipped at the top by tile-body overflow:hidden.
- Add multi_device_enabled: false to DEFAULT_SETTINGS in settings.py
- Add fifth 'Multi-Device' tab (data-tab='devices') to settings dialog sidebar
- Move #setting-device-name, #setting-remote-instances, #add-remote-instance-btn,
#setting-view-mode, and #setting-view-scope out of Display/Sessions tabs into
the new dedicated devices panel
- Add #setting-multi-device-enabled checkbox at top of Multi-Device tab;
when unchecked, all other fields in the tab are greyed out and disabled
- Gate buildSources() remote instances behind multi_device_enabled (smart default:
treated as enabled if remote_instances is non-empty for backward compat)
- Wire device name → document.title: updates title on openSettings() and on
every keystroke in the device name input field
- Add _updateMultiDeviceFieldsState() helper to enable/disable fields
- Add #multi-device-fields CSS transition for smooth opacity animation
- Add 19 new tests (settings, HTML, JS, CSS) covering all new behavior
Mobile: 100vh includes browser chrome on mobile — bottom row clipped.
Fix: 100dvh with 100vh fallback (progressive enhancement) in .view
and .session-tile--expanded.
Fit view: scrollTop hack reset every 2s by poll cycle and had no effect
when content didn't overflow. Fix: replace position:absolute+scrollTop
with flex+justify-content:flex-end on tile-body. Content naturally
anchors to bottom without JS. No scrollbar hiding needed.
Tests: remove test_fit_view_pre_has_top_zero (old behavior), add
test_view_uses_dvh_fallback, test_fit_view_tile_body_uses_flex_end,
test_fit_view_pre_is_static. Update JS test for new flex approach.
- renderGrid early-return now builds status tiles for auth_required and
unreachable sources even when visible.length === 0. Hides empty-state
when status tiles are present, shows it only when truly nothing to display.
- _previewClickHandler now looks up the session in _currentSessions to
recover its sourceUrl before calling openSession, ensuring remote
sessions opened via hover-preview connect to the correct instance
instead of falling back to the local API path.
Tests added (3 new, 214 → 217 total):
- renderGrid shows auth tile and hides empty-state when no sessions but source is auth_required
- renderGrid shows offline tile and hides empty-state when no sessions but source is unreachable
- _previewClickHandler looks up sourceUrl from _currentSessions before calling openSession
Closes code-review Important issues #1 and #2.
Co-authored-by: Amplifier <amplifier@anthropic.com>
- In buildTileHTML, make data-source-url attribute conditional: only add when
session.sourceUrl is non-empty (local sessions omit the attribute entirely)
- In renderGrid click/keydown handlers, pass sourceUrl with || '' fallback so
local sessions (where data-source-url is absent) pass empty string to openSession
- Add 2 tests: one verifying data-source-url present for remote sessions,
one verifying data-source-url absent for local sessions (empty sourceUrl)
Task: task-9-grid-click-routes-source-url
- Guard DELETE /api/sessions/current with if (!_viewingSourceUrl) check
- Reset _viewingSourceUrl to '' after the conditional DELETE
- For remote sessions, the remote instance doesn't need to know we
stopped watching - just disconnect WebSocket and update local UI
Tests added:
- closeSession does NOT fire DELETE for remote session (non-empty _viewingSourceUrl)
- closeSession still fires DELETE /api/sessions/current for local session
Task: task-8-close-session-remote-aware
Bug 1: closeSession() didn't call applyFitLayout when returning to
dashboard — tiles kept old heights from before the overview was hidden.
Fix: add rAF call after overview.style.display = '' so the layout is
remeasured once the overview becomes visible again.
Bug 2: Only 20 lines shown in tiles — not enough for tall fit-mode
tiles, and content sat at the top. Fix:
- buildTileHTML shows 80 lines in fit mode (vs 20 in auto)
- renderGrid rAF sets pre.scrollTop = pre.scrollHeight after
applyFitLayout so content anchors at the bottom (like a real
terminal). Hidden scrollbar via scrollbar-width:none + ::-webkit.
Tests: 3 new source-scan tests added and verified RED before GREEN.
- Add openLoginPopup(remoteUrl) function after buildOfflineTileHTML that strips
trailing slashes from remoteUrl before appending /login and opens a popup
window with width=500,height=600
- Wire up delegated click handler for .source-tile__login-btn buttons inside
bindStaticEventListeners, right after the existing delegated kill-session handler
- Export openLoginPopup from module.exports
Tests: 4 new tests covering openLoginPopup export, window.open call
with correct URL and dimensions, trailing slash handling (with and without)
Bug 1: applyFitLayout measured parentElement.clientHeight before the
browser reflowed the DOM — returned 0 on first render. Fix: wrap in
requestAnimationFrame so measurement happens after layout pass.
Applies to all three call sites: renderGrid(), applyDisplaySettings(),
and the window resize handler.
Bug 2: .tile-body pre had position:absolute bottom:0 but no top:0.
In fit mode (taller tiles), content sat at the bottom with a gap above.
Fix: .session-grid--fit .tile-body pre { top: 0; overflow-y: auto }
- Append auth tiles for auth_required sources after session tiles
- Append offline tiles for unreachable sources after session tiles
- _setSources test helper was already defined and exported (verified)
- All 197 tests pass (195 pre-existing + 2 new)
- Add formatLastSeen(ms) to format millisecond timestamps into relative
strings (Xs ago, Xm ago, Xh ago, Xd ago, Never for null)
- Add buildOfflineTileHTML(source) to render unreachable device tiles
with Offline badge and last-seen timestamp
- Both functions added after buildAuthTileHTML in app.js
- Both exported via module.exports
- 7 new tests covering export, CSS class, device name, badge, last-seen,
HTML escaping, and null handling
- Add buildAuthTileHTML(source) function to frontend/app.js after buildSidebarHTML
- Function renders an <article class='source-tile source-tile--auth'> with device name,
login button (with data-url), and hint text
- HTML-escapes both name and url to prevent XSS
- Export buildAuthTileHTML under '// Federation tiles' comment in module.exports
- Add 5 tests covering: export, article class, device name, login button data-url, HTML escaping
Task: task-2-build-auth-tile-html
Replace s.name with s.sessionKey || s.name as the map key in
detectBellTransitions so two sessions with the same name on
different devices are tracked independently.
- prevMap construction uses s.sessionKey || s.name as key
- next filter lookup uses the same s.sessionKey || s.name key
- Falls back to s.name when no sessionKey is present (backward compatible)
Co-authored-by: Amplifier <amplifier@example.com>
When opts.sourceUrl is non-empty (remote session), skip the POST
/api/sessions/{name}/connect call and skip window._openTerminal(name).
Remote terminal connections are deferred to Phase 3.
Local sessions (empty sourceUrl) continue to work as before.
Co-authored-by: Amplifier <amplifier@amplified.dev>
- Replace renderSidebar to group sessions by deviceName when _sources.length > 1
- Render sidebar-device-header h4 elements before each device's sessions
- When single source configured, render flat list with no headers
- Click handlers now pass sourceUrl to openSession
- Add tests: groups with header when multiple sources, flat when single source
🤖 Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
- Deduplicate CSS: merge shared properties of .settings-remote-url and
.settings-remote-name into a combined selector, keeping only flex
values in individual rules. Reduces 18 lines to 10.
- Fix remove button hover color: .settings-remote-remove:hover now uses
#ef4444 (red) and rgba(239, 68, 68, 0.1) background, consistent with
.tile-delete:hover and .sidebar-delete:hover for destructive actions.
- Add aria-label attributes to urlInput and nameInput in
_buildRemoteInstanceRow for screen-reader accessibility:
'Remote instance URL' and 'Remote instance display name'.
- Add 2 tests to verify aria-label attributes are set on both inputs
(test_build_remote_instance_row_url_input_aria_label,
test_build_remote_instance_row_name_input_aria_label).
All 615 tests pass.
Co-authored-by: Amplifier <amplifier@sourcegraph.com>
- Add loadGridViewMode() that reads grid view mode preference based on
viewPreferenceScope in display settings: returns gridViewMode from
localStorage display settings when scope is 'local' (default), or
grid_view_mode from _serverSettings when scope is 'server'. Returns
'flat' as default when no preference is set.
- Add saveGridViewMode(mode) that saves to appropriate scope (localStorage
via saveDisplaySettings when local, patchServerSetting when server)
and updates _gridViewMode module variable.
- Wire loadGridViewMode() into DOMContentLoaded startup after applyDisplaySettings
so _gridViewMode is initialized from stored preference on page load.
- Export both functions from module.exports for testing.
Closes task-13
- Fixed imprecise pill count assertion in test_app.mjs by changing /filter-pill/g regex to /<button/g to accurately count button elements without double-counting when active pill has both classes
- Removed redundant OR fallback in active pill test, now relying only on specific regex for Laptop pill
- Added defensive null guard 'allSessions = allSessions || []' at entry of renderFilterBar in app.js to prevent TypeError
🤖 Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
- Add <div id="filter-bar" class="filter-bar"> to index.html before session-grid div
- Implement renderFilterBar(container, allSessions) that renders pill buttons
for each unique device name plus an 'All' button
- Active device pill gets filter-pill--active class
- Add _setActiveFilterDevice(device) test helper
- Bind delegated click handler on filter-bar in bindStaticEventListeners
that sets _activeFilterDevice and re-renders grid
- Export renderFilterBar and _setActiveFilterDevice
Co-authored-by: Amplifier <amplifier@amplified.dev>
- Add renderGroupedGrid(sessions, mobile) that groups sessions by deviceName
and creates <h3 class="device-group-header"> headers for each group
- Rewrite renderGrid to: apply device filter in 'filtered' mode, apply sort
order, call renderGroupedGrid when _gridViewMode === 'grouped', render
filter bar in 'filtered' mode, bind click handlers with sourceUrl
- Add _setGridViewMode(mode) test-only helper
- Export renderGroupedGrid and _setGridViewMode from module.exports
- Extract inline ternary for device badge into `const badgeHtml` variable
above the return block, consistent with how `bellHtml` is handled
- Tighten data-session-key/data-source-url test assertions to check exact
attribute values rather than just attribute presence
- Add XSS test: verifies deviceName containing HTML is escaped in the badge
All 159 tests pass.
- Show <span class="device-badge"> after tile name when _sources.length > 1
and session.deviceName is present (multi-source federation context)
- Add data-session-key and data-source-url attributes to tile article
element for unique session identification across sources
- Tests: 3 new tests covering badge visibility, badge omission for single
source, and presence of new data attributes
- Replace getVisibleSessions filter logic: only hide sessions where
sourceUrl is empty/absent AND name matches hidden_sessions list
- Remote sessions with the same name as a hidden local session now
remain visible (task-7 spec requirement)
- Export getVisibleSessions in module.exports so tests can access it
Tests added:
- 'getVisibleSessions exported and filters hidden sessions': verifies
export and that local hidden sessions are filtered
- 'getVisibleSessions hides local sessions by name but not remote
sessions with same name': verifies remote sessions survive
All 155 tests pass.
After fetching /api/settings and caching in _serverSettings,
call _sources = buildSources(_serverSettings) so the polling
layer immediately knows which sources to query.
Implements task-6 of multi-device federation phase 1.