Issue 1: Add '+ New View' to sidebar dropdown
- Add '+ New View' action button (data-action='new-view') to renderSidebarViewDropdown()
- Add showSidebarNewViewInput() function targeting #sidebar-view-dropdown-menu
- Wire sidebar dropdown click handler to call showSidebarNewViewInput()
- Also wire 'Manage Views' action in sidebar dropdown
Issue 2: Remove keyboard shortcut numbers, add session counts
- Remove <span class='view-dropdown__shortcut'> elements from both render functions
- Show count of non-hidden sessions next to 'All Sessions'
- Show each user view's session count (view.sessions.length) in parentheses
- Keep keyboard shortcuts functional (backtick, 1-9) — just remove visual display
Issue 3: Empty new view opens Add Sessions panel
- Call openAddSessionsPanel() after switchView() in showNewViewInput() Enter handler
- Same in showSidebarNewViewInput() — user immediately sees panel to populate new view
Issue 4: Add '+ New View' option to tile flyout submenu
- Add separator + '+ New View' button at bottom of _openFlyoutSubmenu() view list
- Click handler: close flyout, prompt for name, validate, create view with session, switchView
- Remove early exit when views.length === 0 (show New View option even with no views)
Issue 5: Fix openAddSessionsPanel entry point + move to header
- Remove broken add-sessions-tile with onclick='window.MuxplexApp.openAddSessionsPanel()'
- Add <button id='add-sessions-btn'> to header-actions in index.html (hidden by default)
- Add updateAddSessionsButton() to show/hide button based on active view
- Wire button click to openAddSessionsPanel() in bindStaticEventListeners()
- Call updateAddSessionsButton() from switchView() and DOMContentLoaded
Issue 6: Fix device badge overlapping flyout icon
- Move tile-options-btn inside tile-header as last flex item (was absolute sibling of article)
- Move device badge out of tile-meta, now a direct flex sibling between tile-name and tile-meta
- Remove tile-meta-sep separator (badge no longer in meta)
- Add gap: 4px to .tile-header CSS
- Remove position:absolute from .tile-options-btn CSS — flex-shrink:0 instead
Tests: Add 17 new static analysis tests in test_frontend_js.py for all 6 issues.
Update test_app.mjs tile tests to match new tile-header layout.
All 318 frontend tests + 224 backend tests pass.
Race condition: showNewViewInput() calls replaceChild() to swap the
'+ New View' button for an <input>. The click event then bubbles to the
document-level click-outside handler where e.target is the removed button,
no longer in the DOM. dropdown.contains(e.target) returns false, so
closeViewDropdown() fires immediately and the input disappears.
Fix: before closing, check whether the dropdown now contains a
.view-dropdown__new-input element. If it does, showNewViewInput() just
ran and we must not close the dropdown.
Test: added test_click_outside_view_dropdown_guards_against_new_view_input
to verify the guard is present in the handler.
Fix A: Add .flyout-sheet__title CSS rule in style.css flyout-sheet section.
- Kill confirm sheet renders a title div with class flyout-sheet__title
but no CSS rule existed; title displayed as raw unstyled text.
- Added padding/font/color/alignment rule after .flyout-sheet__handle.
Fix B: Change role='menuitem' to role='button' on Kill and Cancel buttons
in _openMobileKillConfirm() (app.js).
- Buttons inside a role='alertdialog' must use role='button' per ARIA spec;
role='menuitem' is only valid inside role='menu'.
Fix C: Remove 3 orphaned .sidebar-delete CSS rule blocks from style.css.
- .sidebar-delete, .sidebar-item:hover .sidebar-delete, .sidebar-delete:hover
were dead code after the delete button was removed from buildSidebarHTML().
Tests added:
- test_frontend_css.py: test_flyout_sheet_title_css_exists (Fix A)
- test_frontend_js.py: test_kill_confirm_buttons_use_role_button (Fix B)
- test_frontend_css.py: test_no_sidebar_delete_css (Fix C)
Fix 1: BEM class names — renderViewDropdown/renderSidebarViewDropdown now emit
view-dropdown__item, __separator, __shortcut, __action, __count matching CSS
Fix 2: role=menuitem — all buttons in both dropdown renderers now carry
role="menuitem" so handleGlobalKeydown arrow-key navigation works
Fix 3: Sidebar dropdown clipping — #sidebar-view-dropdown-menu changed to
position:fixed; toggleSidebarViewDropdown uses getBoundingClientRect() to
escape .session-sidebar { overflow:hidden } clipping
Fix 4: manage-views wiring — bindStaticEventListeners now calls
openSettings() + switchSettingsTab('views') instead of falling through to
closeViewDropdown only
Fix 5: active_view persistence — delete and rename paths in
renderViewsSettingsTab now PATCH /api/state when _activeView changes
Fix 6: Sidebar dropdown click-outside — second document click listener
closes sidebar-view-dropdown-menu on outside clicks
Fix 7: Case-insensitive reserved names — showNewViewInput and commitRename
use name.toLowerCase() for 'all'/'hidden' check
Fix 8: Rename input maxLength — views-settings-rename-input now has
maxLength=30 matching the creation input
Fix 9: Local device_id — added _localDeviceId variable, fetched from
/api/instance-info at startup; createNewSession uses _localDeviceId instead
of _serverSettings.device_id (which is not in /api/settings response)
Fix 10: Dead code — renderFilterBar body emptied; dead filter-bar click
handler removed from bindStaticEventListeners
Tests: 24 new tests added covering all 10 findings; 1070 total tests pass
Remove the Hidden Sessions checkbox list from the Sessions settings tab.
The feature is superseded by the Hidden view + tile flyout (Phase 3),
which is accessible via the sidebar view dropdown.
Changes:
- muxplex/frontend/index.html: Remove #setting-hidden-sessions div block
- muxplex/frontend/app.js: Remove hidden sessions checkbox population
block from openSettings() and delegated change handler from
bindStaticEventListeners()
- muxplex/tests/test_frontend_html.py: Remove old
test_html_sessions_panel_has_hidden_sessions_container (for removed
element); add test_no_hidden_sessions_checkbox_list_in_settings
asserting element no longer exists
- muxplex/tests/test_frontend_js.py: Remove
test_bind_static_event_listeners_uses_delegated_handler_for_hidden_sessions
and test_settings_hidden_sessions_uses_session_key (for removed JS code)
- Remove 'Filtered' option from HTML view mode select in index.html
- Add test_no_active_filter_device_in_render_grid to test_frontend_js.py
- Add test_no_filtered_option_in_view_mode_select to test_frontend_html.py
- All 3 acceptance criteria tests pass
The renderGrid() function was already clean (no filtered mode checks,
no _activeFilterDevice references) from prior cleanup work. The
loadGridViewMode() and _setGridViewMode() guards were also already in
place. The remaining task was to remove the HTML <option value="filtered">
and add the missing tests.
- HTML: Replace .sidebar-title in sidebar-header with a view dropdown
containing #sidebar-view-dropdown-trigger and #sidebar-view-dropdown-menu
- JS: Add renderSidebarViewDropdown() for populating sidebar menu (no action buttons)
- JS: Add toggleSidebarViewDropdown() for open/close with aria-expanded
- JS: Update switchView() to also update #sidebar-view-label in sidebar
- JS: Bind sidebar trigger click and delegated menu click in bindStaticEventListeners()
- CSS: Add .sidebar-view-dropdown, .sidebar-view-trigger, .sidebar-view-trigger:hover,
and #sidebar-view-dropdown-menu position overrides
- Tests: Add test_sidebar_view_dropdown_exists and test_sidebar_view_dropdown_menu_exists
- Tests: Update test_html_session_sidebar_structure to reflect new dropdown structure
- Remove unused 'sessions' variable in renderViewsSettingsTab() forEach loop
(was declared but never used; sessionCount already derived from view.sessions directly)
- Add 'committed' flag to commitRename() closure to prevent duplicate PATCH
requests when Enter-triggered DOM teardown fires blur on the still-live input
(pattern mirrors the 150ms guard already used in showNewViewInput())
Implements the "Manage Views" Settings Tab with full UI and functionality:
HTML: Added Views tab button and panel with empty state message.
JS: Added renderViewsSettingsTab() function that renders the view list with:
- Clickable name for inline rename (validates reserved/duplicate names)
- Session count display
- Up/down arrow buttons for reordering (disabled at boundaries)
- Delete button with inline 'Sure? [Yes] [No]' confirmation
- Delegated event handlers for all interactions
Added _saveViewsAndRerender() helper that PATCHes /api/settings,
updates _serverSettings, and re-renders both tab and dropdown.
Updated openSettings() to call renderViewsSettingsTab() after loading.
CSS: Added styling for views-settings-list, views-settings-row,
views-settings-name, views-settings-count, views-settings-actions,
views-settings-btn, views-settings-confirm, and rename input.
Tests: Added 3 new frontend tests and updated existing panel count test.
All 491 frontend tests pass with no regressions.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
- Add showNewViewInput() function after closeViewDropdown() in app.js
- Creates inline text input replacing '+ New View' button in dropdown
- Input: type=text, class=view-dropdown__new-input, placeholder='View name',
maxLength=30, aria-label='New view name'
- Re-focuses existing input if already present (prevents duplicates)
- On Enter: validates name (non-empty, non-reserved, non-duplicate),
PATCHes /api/settings with updated views, switches to new view on success
- On Escape: closes the dropdown
- On blur: closes dropdown after 150ms delay if input not focused
- Update bindStaticEventListeners to call showNewViewInput() for data-action='new-view'
instead of just closeViewDropdown()
- Export showNewViewInput in module.exports block
Tests added:
- test_show_new_view_input_function_exists: verifies function exists in app.js
- test_show_new_view_input_patches_settings: verifies PATCH /api/settings with views
Task: task-8
- Backtick (Backquote) toggles view dropdown in grid mode only (not fullscreen)
- Number keys 1-9 directly switch views (1=all, 9=hidden, 2-8=user views)
- Arrow keys navigate within open dropdown using [role="menuitem"] elements
- Enter activates the focused dropdown item
- Escape closes open dropdown when one is visible
- Shortcuts disabled when typing in INPUT/TEXTAREA/SELECT
- Input guard applied globally (not just for comma shortcut)
Tests added:
- test_handle_global_keydown_has_backtick_handler
- test_handle_global_keydown_has_number_key_shortcuts
- test_backtick_only_on_grid_not_fullscreen
All 229 tests pass (226 existing + 3 new)
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