textContent is inherently XSS-safe and does not interpret HTML entities.
Wrapping with escapeHtml() caused literal entity strings (e.g., &)
to display instead of the intended characters (e.g., &) for view names
containing special characters like &, <, or >.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
- Add renderViewDropdown() populating #view-dropdown-menu with All Sessions,
user views, Hidden (count), + New View, Manage Views; updates label
- Add toggleViewDropdown() toggling hidden class and aria-expanded
- Add closeViewDropdown() hiding menu and cleaning up inline inputs
- Add switchView(viewName) setting _activeView, re-rendering, persisting
active_view via PATCH /api/state (fire-and-forget)
- Restore _activeView from server state in restoreState()
- Bind view dropdown event listeners in bindStaticEventListeners():
trigger click, delegated menu item clicks, click-outside dismiss
- Export all 4 new functions in module.exports
- Add 5 new tests: render_view_dropdown_function_exists,
render_view_dropdown_exported, toggle_view_dropdown_function_exists,
switch_view_function_exists, switch_view_patches_state
Add CSS styles for the header view dropdown component:
- .view-dropdown wrapper with relative positioning and flex layout
- .view-dropdown__trigger with transparent border, 6px radius, transitions
- .view-dropdown__trigger:hover and [aria-expanded=true] states
- .view-dropdown__caret with 10px font and muted color
- .view-dropdown__menu absolutely positioned below trigger, z-index: 100
- .view-dropdown__item with flex layout, text-overflow ellipsis
- .view-dropdown__item:hover/:focus-visible with bg-surface
- .view-dropdown__item--active with accent color and checkmark pseudo-element
- .view-dropdown__shortcut right-aligned with monospace font
- .view-dropdown__separator as 1px height divider
- .view-dropdown__action and :hover states
- .view-dropdown__new-input with accent border
- .view-dropdown__count with dim color
Add 3 TDD tests: test_view_dropdown_trigger_styled,
test_view_dropdown_menu_styled, test_view_dropdown_item_styled
- Add #view-dropdown-trigger button between wordmark and header-actions
- Add #view-dropdown-menu container with role='menu' and hidden class
- Add #view-dropdown-label with default text 'All Sessions'
- Add caret span with aria-hidden='true'
- Trigger has aria-haspopup='true', aria-expanded='false', aria-controls attributes
- All wrapped in .view-dropdown div with id='view-dropdown'
Tests added:
- test_view_dropdown_trigger_exists
- test_view_dropdown_container_exists
- test_view_dropdown_trigger_has_aria
- test_view_dropdown_menu_has_role_menu
All 93 tests pass (4 new + 89 existing).
When a session is created via the + button while a user view is active,
auto-add the new session's key to that view's sessions list.
- In createNewSession(), after the successful POST call, check _activeView
- Skip auto-add for reserved views ('all', 'hidden')
- Build sessionKey correctly using device_id or remoteId
- PATCH /api/settings with updated views, catch errors gracefully
- Update local _serverSettings.views cache immediately
Test: test_create_new_session_references_active_view
- Add _activeView state variable with default 'all' in App state section
- Rewrite getVisibleSessions() to filter by active view:
- 'all' view: excludes hidden sessions
- 'hidden' view: shows only hidden sessions
- user view: filters to view's sessions list using sessionKey
- Falls back to 'all' if active view no longer exists
- Add _getActiveView() and _setActiveView() test helpers
- Export new test helpers in window.MuxplexApp (module.exports)
- Add 6 new tests covering all acceptance criteria
Tests added:
- test_active_view_state_variable_exists
- test_get_visible_sessions_all_view_excludes_hidden
- test_get_visible_sessions_hidden_view_shows_hidden
- test_get_visible_sessions_user_view_filters_by_session_key
- test_get_active_view_helper_exported
- test_set_active_view_helper_exported
All 220 tests pass.
- Add optional active_view field to StatePatch Pydantic model
- Add active_view handling in patch_state to persist the field
- Add test_patch_state_sets_active_view: verifies PATCH persists active_view
- Add test_patch_state_active_view_defaults_to_all: verifies GET returns 'all' by default
Closes task-1: Add active_view to StatePatch Model
- BUG 1: Replace isinstance(active_remote_id, int) guard with _lookup_remote_by_device_id()
so bell-clear fires correctly when active_remote_id is a UUID string (new format).
Old code silently skipped the POST for all non-integer active_remote_id values.
Regression test: test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uuid
- GAP 4: Add _resolveActiveView(activeView, views) helper in app.js.
Falls back to 'all' when active_view references a deleted/unknown view name.
Ready for Phase 2 view-switching code to call at read time.
- ISSUE 6: _createDeviceSelect now uses remotes[i].device_id || String(i) for option
values instead of always String(i), so session creation routes receive correct device_id.
- ISSUE 9: identity.json written with indent=2 + trailing newline, consistent with
settings.json and state.json formatting conventions.
- ISSUE 11: Fix state.py module docstring — 'tmux-web muxplex' → 'muxplex'.
- ISSUE 12: Settings panel hidden_sessions checkboxes now use s.sessionKey || s.name
as the checkbox value (and checked state) so remote sessions are stored in
device_id:name format, consistent with getVisibleSessions() lookups.
- renderGrid: remove all _gridViewMode === 'filtered' branches (device
filter application and filter bar rendering); filter bar is now always
cleared for flat/grouped modes
- loadGridViewMode: add 'filtered' → 'flat' fallback so existing users
who had the setting stored don't get a broken state
- _setGridViewMode (test helper): same guard so tests can't accidentally
set the removed mode
- test_frontend_js.py: three new static-analysis tests assert the guards
are present and the renderGrid filtered checks are gone
- buildTileHTML: prefer session.deviceId over legacy integer remoteId for data-remote-id
- buildSidebarHTML: prefer session.deviceId over legacy integer remoteId for data-remote-id
- getVisibleSessions: check s.sessionKey (device_id:name) in hidden_sessions list for
backward compatibility with both old (plain name) and new (device_id:name) formats
- openSession: rename _remoteId → _deviceId to reflect value is now a device_id string
- createNewSession: rename internal remoteId variable → deviceId for clarity
All federation API URLs continue to work via opts.remoteId (now contains deviceId value).
Backward compatibility maintained: fallback to remoteId when deviceId not present (old server).
Tests: updated test_open_session_fires_bell_clear_for_remote to check _deviceId guard;
added 5 new tests covering all structural changes.
Ruff was reformatting the ternary header constructions to multi-line form:
headers={"Authorization": f"Bearer {remote_key}"}\n if remote_key\n else {},
This caused test_federation_auth_headers_guard_empty_key to fail because
the source-inspection test requires 'if' to appear on the same line as
the Authorization header dict.
Fix: assign to a local variable before use so ruff has no reason to split:
auth_headers = {"Authorization": f"Bearer {key}"} if key else {}
Applied at two sites:
- poll-cycle bell-clear (line ~232)
- federation WS proxy endpoint (line ~1094)
Change all 5 federation proxy URL patterns from {remote_id:int} to
{device_id} (str) and use _lookup_remote_by_device_id() for lookup.
Affected endpoints:
- federation_connect: /api/federation/{device_id}/connect/{session_name}
- federation_bell_clear: /api/federation/{device_id}/sessions/{session_name}/bell/clear
- federation_create_session: /api/federation/{device_id}/sessions
- federation_delete_session: /api/federation/{device_id}/sessions/{session_name}
- federation_terminal_ws_proxy: /federation/{device_id}/terminal/ws
All endpoints now use _lookup_remote_by_device_id(device_id) which
provides integer index fallback for backward compatibility.
Also update existing test to expect 404 instead of 422 for non-integer
device_id (since str type accepts any string, lookup returns None->404).
Tests:
- test_federation_connect_by_device_id: POST with device_id='aaa-111-bbb' returns 200
- test_federation_connect_device_id_not_found: unknown device_id returns 404
Implements _lookup_remote_by_device_id(device_id: str) -> dict | None
in muxplex/main.py, inserted before the federation WebSocket proxy section.
Logic:
- Load settings and get remote_instances list
- Primary: iterate remotes, return first where remote.get('device_id') == device_id
- Fallback: if device_id parses as integer, use index-based lookup for
transition compatibility (0 <= idx < len(remotes))
- Return None if not found
Tests added:
- test_lookup_remote_by_device_id_found: two remotes with device_ids
'aaa-111' and 'bbb-222'; lookup 'bbb-222' returns Desktop remote
- test_lookup_remote_by_device_id_not_found: one remote with device_id
'aaa-111'; lookup 'zzz-999' returns None
Task: task-8
- Add clarifying comment to lazy import of enforce_mutual_exclusion
in apply_synced_settings() to document circular-import avoidance pattern
- Remove redundant 'import json' from inside
test_apply_synced_settings_enforces_mutual_exclusion()
since json is already imported at module level
No behavior changes. All 62 tests pass.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
- Call enforce_mutual_exclusion(current) in apply_synced_settings() after
applying SYNCABLE_KEYS and before save_settings()
- Import enforce_mutual_exclusion from muxplex.views inside the function
to avoid circular import issues
- Update docstring to describe the mutual exclusion invariant repair step
- Add test_apply_synced_settings_enforces_mutual_exclusion to verify that
sessions present in both hidden_sessions and a view's sessions are
removed from hidden_sessions after sync (task-7)
Add reset_device_id_command() function to cli.py that:
- loads the current device_id via load_device_id()
- generates a new device_id via reset_device_id()
- prints new device_id, identity file path, previous device_id
- warns that existing session keys are now orphaned
Register 'reset-device-id' subparser with appropriate help text
and add dispatch branch in main().
Tests added:
- test_reset_device_id_writes_new_id: verifies command writes new ID
and prints required output including orphan warning
- test_main_dispatches_to_reset_device_id: verifies CLI routing
Add load_device_id import from muxplex.identity and include device_id
in the instance-info response dict. Update endpoint docstring to
mention device identity.
Test: test_instance_info_includes_device_id verifies device_id is
present as a non-empty string when IDENTITY_PATH is redirected to
tmp_path.
- Change _default_state_dir from 'tmux-web' to 'muxplex'
- Update STATE_DIR to prefer MUXPLEX_STATE_DIR env var with TMUX_WEB_STATE_DIR fallback
- Add 'active_view': 'all' to empty_state() between active_remote_id and session_order
- Update module docstring schema to document active_view field
- Add tests: test_empty_state_has_active_view_key, test_empty_state_active_view_defaults_to_all, test_state_dir_uses_muxplex_name
- Add muxplex/identity.py with IDENTITY_PATH, load_device_id(), reset_device_id()
- load_device_id() creates identity.json when absent, regenerates on corrupt JSON
or missing device_id key, creates parent directories as needed
- reset_device_id() generates a new UUID v4, overwrites identity.json, returns it
- Add muxplex/tests/test_identity.py with 8 tests covering all spec requirements
Adds design specification for user-defined Views — curated session
collections that span devices and replace the filtered gridViewMode.
Covers:
- Stable device identity prerequisite (device_id UUID in identity.json)
- Data model: views array in settings, active_view in state
- Three view tiers: All (virtual), user-created, Hidden (virtual)
- Mutual exclusion invariant between hidden and view membership
- UI: header dropdown view switcher, tile flyout menu, add sessions panel
- Migration strategy for session key format change
- Known limitations: rename breakage, atomic sync, shortcut cap
setInterval fires regardless of whether the previous async call has
completed. When federation requests time out (5s) during 2s poll cycles,
multiple requests stack up. Chrome's 6-connection-per-origin limit is
quickly exhausted → ERR_INSUFFICIENT_RESOURCES → death spiral.
Fix: self-scheduling setTimeout pattern — poll completes → wait → next
poll. At most one in-flight request at a time. Applied to both
pollSessions and sendHeartbeat loops.
A sentinel (true) is set on _pollingTimer/_heartbeatTimer immediately in
startPolling/startHeartbeat so the double-start guard works even during
the first async call before the real timer ID is assigned.
The first remote instance (index 0) couldn't be opened from the mobile
bottom sheet because s.remoteId ? treated 0 as falsy. Changed to
s.remoteId != null to match buildTileHTML and buildSidebarHTML.
Two federation UX fixes:
1. Zero-session devices: when remote returns empty sessions list, return
{status: 'empty'} entry. Frontend renders 'No sessions' status tile
instead of making the device invisible.
2. Flapping prevention: server-side cache of last-known-good federation
results per remote. On transient failure, return cached sessions for
up to 3 consecutive failures before marking unreachable. Eliminates
the visible on/off/on/off pattern caused by occasional 5-second
timeouts during 2-second poll cycles.
Tests added:
- test_fetch_remote_returns_empty_status_for_zero_sessions (Python)
- test_fetch_remote_uses_cache_on_transient_failure (Python)
- test_fetch_remote_marks_unreachable_after_grace_period (Python)
- renderGrid shows 'No sessions' status tile for status=empty devices (JS)
Also adds reset_federation_cache autouse fixture to prevent cross-test
contamination from the new module-level _federation_cache dict.
buildStatusTileHTML received session.name (undefined for status entries)
instead of session.deviceName. Offline/unreachable tiles showed blank.
Fixed all 4 call sites in renderGrid to pass session.deviceName.
Updated two existing tests to use deviceName (not name) in status entry
objects — reflecting the real federation data shape.
Unreachable/auth_failed status entries from the federation response
were rendered twice: once as a blank session tile (no name) by
buildTileHTML, and again as an 'Offline' status tile by the separate
status rendering loop. Fix: getVisibleSessions() now filters out
entries with a status field. Status tiles are still rendered separately
from the full sessions array.
The touch scroll handler was only enabled for Android devices,
leaving iOS/iPad users unable to scroll through terminal history.
Changes:
- Extend UA detection to include iPhone, iPad, and iPod
- Add iPadOS 13+ detection (reports as MacIntel with touch points)
- Rename function to initMobileTerminalScroll for clarity
Fixes#3
Bug 1 (auth.py): AuthMiddleware.dispatch() was using self.federation_key
(set once at startup) for Bearer token validation. If the key was generated
or rotated via POST /api/federation/generate-key after startup, the old
(often empty) value caused all federation auth to silently return 401.
Fix: import load_federation_key from muxplex.settings and call it fresh on
every non-exempt, non-cookie request. Also adds a warning log when a Bearer
token is received but no key is configured on this server.
Bug 2 (main.py): _sync_settings_with_remotes() discarded the PUT response,
silently swallowing 401/500 errors from the remote sync endpoint.
Fix: capture the PUT response as put_resp. Handle 409 (Conflict = remote is
newer) with a debug log; raise_for_status() for any other non-2xx so errors
propagate to the outer except and are logged as warnings.
Tests:
- test_dispatch_calls_load_federation_key_live: pattern test confirming
load_federation_key() is called inside dispatch()
- test_dispatch_does_not_use_stale_self_federation_key_for_bearer: pattern
test confirming self.federation_key is gone from the live bearer check
- test_sync_put_response_calls_raise_for_status: pattern test confirming
raise_for_status() is called on the PUT response in the sync function
- Updated existing bearer tests to monkeypatch load_federation_key so they
are isolated from any real key file on disk
- Add SETTINGS_SYNC_INTERVAL = 15 constant (15 poll cycles × 2s ≈ 30s)
- Add _settings_sync_counter module-level counter
- Add _sync_settings_with_remotes() async function that:
- GETs /api/settings/sync from each remote instance
- Adopts remote settings if remote timestamp is newer
- Pushes local settings via PUT if local timestamp is newer
- Silently skips 404/405 (older muxplex without sync endpoints)
- Catches all per-remote errors and logs as warnings
- Wire sync call into _run_poll_cycle() step 13 (runs outside state_lock)
- Add test_settings_sync_poll.py with 10 tests covering all sync behaviours
Captures the exception as 'exc' in the except clause and includes it
as the third argument to the warning log message. This ensures that
the failure reason is observable in logs, improving diagnostics for
federation heartbeat failures.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>