# Views Feature — Phase 3: Tile Flyout Menu + Add Sessions Panel + Final Integration > **Execution:** Use the subagent-driven-development workflow to implement this plan. **Goal:** Build the tile-level interaction UI (flyout `⋮` menu with context-dependent actions, inline kill confirmation) and the Add Sessions panel, then verify all three phases work together end-to-end. **Architecture:** An always-visible `⋮` button on every session tile opens a floating flyout menu (appended to `document.body` with `position: fixed` and JS-calculated coordinates to avoid z-index/stacking issues). Menu items are generated from a data map keyed by view type (`all`, `user`, `hidden`) — not if/else chains. The flyout replaces the old `confirm()` kill dialog and `.tile-delete` button entirely. A separate Add Sessions panel is an overlay for bulk-adding sessions to a user view with immediate-commit checkboxes. On mobile (`window.innerWidth < 600`), the flyout renders as a bottom action sheet and the Add Sessions panel renders as a full-screen sheet. **Tech Stack:** Python 3.12+ / FastAPI / pytest + pytest-asyncio / vanilla JS / CSS **Design reference:** `docs/plans/2026-04-15-views-design.md` **Phase 1 reference:** `docs/plans/2026-04-15-views-phase1-implementation.md` **Phase 2 reference:** `docs/plans/2026-04-15-views-phase2-implementation.md` **Assumes Phase 1+2 are complete:** `identity.py` exists, session keys use `device_id:name`, `views` is in `DEFAULT_SETTINGS` and `SYNCABLE_KEYS`, `active_view` is in state schema, `views.py` has `enforce_mutual_exclusion()` and `validate_view_name()`, header dropdown works with `renderViewDropdown()` / `toggleViewDropdown()` / `switchView()`, Manage Views tab exists in settings, `getVisibleSessions()` honors active view, `_activeView` state variable exists, `filtered` gridViewMode removed, hidden sessions checkbox removed from settings, `_setActiveView` / `_getActiveView` test helpers exported. --- ## Task 1: Flyout Menu Base Component — CSS **Files:** - Modify: `muxplex/frontend/style.css` - Test: `muxplex/tests/test_frontend_css.py` **Step 1: Write the failing tests** Add to the end of `muxplex/tests/test_frontend_css.py`: ```python # --------------------------------------------------------------------------- # Tile flyout menu styles # --------------------------------------------------------------------------- def test_flyout_menu_styled() -> None: """style.css must contain .flyout-menu styles.""" css = read_css() assert ".flyout-menu" in css, "style.css must style .flyout-menu" def test_flyout_menu_item_styled() -> None: """style.css must contain .flyout-menu__item styles.""" css = read_css() assert ".flyout-menu__item" in css, "style.css must style .flyout-menu__item" def test_flyout_trigger_styled() -> None: """style.css must contain .tile-options-btn styles.""" css = read_css() assert ".tile-options-btn" in css, "style.css must style .tile-options-btn" def test_flyout_submenu_styled() -> None: """style.css must contain .flyout-submenu styles.""" css = read_css() assert ".flyout-submenu" in css, "style.css must style .flyout-submenu" def test_flyout_bottom_sheet_styled() -> None: """style.css must contain .flyout-sheet styles for mobile.""" css = read_css() assert ".flyout-sheet" in css, "style.css must style .flyout-sheet (mobile bottom action sheet)" ``` **Step 2: Run tests to verify they fail** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_css.py::test_flyout_menu_styled -v ``` Expected: FAIL — `.flyout-menu` not in CSS **Step 3: Add styles to `muxplex/frontend/style.css`** Append before the media query sections (before line ~1645). Insert after the views settings tab styles: ```css /* —— Tile Flyout Menu ————————————————————————————————————————————————— */ .tile-options-btn { position: absolute; top: 6px; right: 6px; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: var(--bg-tile); border: 1px solid var(--border-subtle); border-radius: 4px; color: var(--text-muted); font-size: 14px; line-height: 1; cursor: pointer; z-index: 2; transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast); } .tile-options-btn:hover, .tile-options-btn:focus-visible { border-color: var(--border); color: var(--text); background: var(--bg-surface); } .flyout-menu { position: fixed; min-width: 200px; max-width: 280px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 4px 0; z-index: 300; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); } .flyout-menu__item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 12px; background: transparent; border: none; color: var(--text); font-size: 13px; cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; position: relative; } .flyout-menu__item:hover, .flyout-menu__item:focus-visible { background: var(--bg-surface); } .flyout-menu__item--danger { color: var(--err); } .flyout-menu__item--danger:hover { background: rgba(248, 81, 73, 0.1); } .flyout-menu__item--has-submenu::after { content: "\25B8"; margin-left: auto; font-size: 10px; color: var(--text-dim); } .flyout-menu__separator { height: 1px; margin: 4px 0; background: var(--border-subtle); } .flyout-menu__confirm { display: flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 13px; color: var(--text-muted); } .flyout-menu__confirm-btn { padding: 2px 8px; border-radius: 4px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 12px; cursor: pointer; } .flyout-menu__confirm-btn:hover { background: var(--bg-surface); } .flyout-menu__confirm-btn--yes { border-color: var(--err); color: var(--err); } .flyout-menu__confirm-btn--yes:hover { background: rgba(248, 81, 73, 0.15); } /* —— Flyout Submenu (Add to View) ————————————————————————————————————— */ .flyout-submenu { position: fixed; min-width: 180px; max-width: 240px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 4px 0; z-index: 310; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); } .flyout-submenu__item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 12px; background: transparent; border: none; color: var(--text); font-size: 13px; cursor: pointer; text-align: left; } .flyout-submenu__item:hover { background: var(--bg-surface); } .flyout-submenu__check { width: 14px; flex-shrink: 0; color: var(--accent); font-size: 12px; } /* —— Mobile: Flyout as bottom action sheet ————————————————————————————— */ .flyout-sheet { position: fixed; inset: 0; z-index: 300; display: flex; align-items: flex-end; } .flyout-sheet__backdrop { position: absolute; inset: 0; background: var(--bg-overlay); } .flyout-sheet__panel { position: relative; width: 100%; background: var(--bg-header); border-top: 1px solid var(--border); border-radius: 12px 12px 0 0; max-height: 70vh; overflow-y: auto; animation: sheet-up var(--t-zoom) ease; padding-bottom: env(safe-area-inset-bottom, 8px); } .flyout-sheet__handle { width: 36px; height: 4px; background: var(--border); border-radius: 2px; margin: 10px auto 6px; } .flyout-sheet__item { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 20px; background: transparent; border: none; color: var(--text); font-size: 15px; cursor: pointer; text-align: left; } .flyout-sheet__item:active { background: var(--bg-surface); } .flyout-sheet__item--danger { color: var(--err); } .flyout-sheet__separator { height: 1px; margin: 4px 0; background: var(--border-subtle); } ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_css.py -k "flyout" -v ``` Expected: All 5 new tests PASS **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/style.css muxplex/tests/test_frontend_css.py && git commit -m "feat: add flyout menu, submenu, and mobile bottom sheet CSS" ``` --- ## Task 2: Add ⋮ Button to Session Tiles + Remove Old Kill Button **Files:** - Modify: `muxplex/frontend/app.js` (the `buildTileHTML()` function, lines 409–458) - Test: `muxplex/tests/test_frontend_js.py` The existing `.tile-delete` `×` button (line 455 of app.js) is replaced by a `⋮` options button. Kill session moves to the flyout menu (Task 7). **Step 1: Write the failing tests** Add to `muxplex/tests/test_frontend_js.py`: ```python # --------------------------------------------------------------------------- # Tile options button (⋮) replaces tile-delete # --------------------------------------------------------------------------- def test_tile_has_options_button() -> None: """buildTileHTML must include a .tile-options-btn button.""" fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0] assert "tile-options-btn" in fn_body, ( "buildTileHTML must render a .tile-options-btn element" ) def test_tile_options_btn_has_aria() -> None: """The ⋮ button must have aria-label='Session options' and aria-haspopup='true'.""" fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0] assert 'aria-label' in fn_body and 'Session options' in fn_body, ( "tile-options-btn must have aria-label='Session options'" ) assert 'aria-haspopup' in fn_body, ( "tile-options-btn must have aria-haspopup='true'" ) def test_tile_delete_button_removed() -> None: """buildTileHTML must NOT include the old .tile-delete button.""" fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0] assert "tile-delete" not in fn_body, ( "buildTileHTML must not render the old .tile-delete button (kill moved to flyout)" ) ``` **Step 2: Run tests to verify they fail** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_tile_has_options_button muxplex/tests/test_frontend_js.py::test_tile_delete_button_removed -v ``` Expected: FAIL — `tile-options-btn` not in `buildTileHTML`, and `tile-delete` still present **Step 3: Apply the change to `muxplex/frontend/app.js`** In `buildTileHTML()` (line 455), replace the old `.tile-delete` button: ```javascript // Before (line 455): `` + // After: `` + ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "tile_has_options or tile_options_btn_has_aria or tile_delete_button_removed" -v ``` Expected: All 3 PASS **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: replace tile-delete button with ⋮ options button on session tiles" ``` --- ## Task 3: Flyout Menu Base JS — Open, Position, Close, Event Delegation **Files:** - Modify: `muxplex/frontend/app.js` - Test: `muxplex/tests/test_frontend_js.py` Implement `openFlyoutMenu(sessionKey, triggerEl)`, `closeFlyoutMenu()`, and the delegated click listener on the tile container that opens the flyout when a `⋮` button is clicked. Also remove the old `.tile-delete` delegated click handler from `bindStaticEventListeners()` (lines 2185–2194). **Step 1: Write the failing tests** Add to `muxplex/tests/test_frontend_js.py`: ```python def test_open_flyout_menu_function_exists() -> None: """app.js must define an openFlyoutMenu function.""" assert "function openFlyoutMenu" in _JS, ( "app.js must contain an openFlyoutMenu function" ) def test_close_flyout_menu_function_exists() -> None: """app.js must define a closeFlyoutMenu function.""" assert "function closeFlyoutMenu" in _JS, ( "app.js must contain a closeFlyoutMenu function" ) def test_flyout_menu_uses_fixed_positioning() -> None: """openFlyoutMenu must use position:fixed and getBoundingClientRect for positioning.""" fn_body = _JS.split("function openFlyoutMenu")[1].split("\nfunction ")[0] assert "getBoundingClientRect" in fn_body, ( "openFlyoutMenu must use getBoundingClientRect to calculate position" ) def test_flyout_delegated_on_tile_container() -> None: """A delegated click listener must handle .tile-options-btn clicks.""" assert "tile-options-btn" in _JS.split("bindStaticEventListeners")[1], ( "bindStaticEventListeners must handle .tile-options-btn clicks via delegation" ) def test_old_tile_delete_handler_removed() -> None: """The old delegated .tile-delete click handler must be removed.""" bind_body = _JS.split("function bindStaticEventListeners")[1].split("\nfunction ")[0] assert "tile-delete" not in bind_body, ( "The old .tile-delete delegated handler must be removed from bindStaticEventListeners" ) ``` **Step 2: Run tests to verify they fail** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_open_flyout_menu_function_exists -v ``` Expected: FAIL — function does not exist **Step 3: Apply the changes to `muxplex/frontend/app.js`** Change 1 — Add a module-level state variable in the "App state" section (after `_previewSessionName` on line 133): ```javascript // Flyout menu state let _flyoutMenuEl = null; let _flyoutSubmenuEl = null; let _flyoutSessionKey = null; let _flyoutSessionName = null; let _flyoutRemoteId = null; ``` Change 2 — Add the flyout functions. Insert after the `showPreview` / `hidePreview` section (after line ~926): ```javascript // ── Tile Flyout Menu ───────────────────────────────────────────────────────── /** * Open the flyout menu for a session tile's ⋮ button. * Creates a floating menu appended to document.body, positioned relative to * the trigger button via getBoundingClientRect. On mobile, renders as a * bottom action sheet instead. * @param {HTMLElement} triggerEl - The .tile-options-btn element that was clicked */ function openFlyoutMenu(triggerEl) { closeFlyoutMenu(); // Read session info from the tile var tile = triggerEl.closest('[data-session-key]'); if (!tile) return; _flyoutSessionKey = tile.dataset.sessionKey || ''; _flyoutSessionName = tile.dataset.session || ''; _flyoutRemoteId = tile.dataset.remoteId || ''; if (isMobile()) { _openFlyoutSheet(); return; } // Build menu items based on active view type var menuHtml = _buildFlyoutMenuItems(); var menu = document.createElement('div'); menu.className = 'flyout-menu'; menu.setAttribute('role', 'menu'); menu.setAttribute('aria-label', 'Session options'); menu.innerHTML = menuHtml; document.body.appendChild(menu); _flyoutMenuEl = menu; // Position relative to trigger var rect = triggerEl.getBoundingClientRect(); var menuWidth = menu.offsetWidth; var menuHeight = menu.offsetHeight; // Default: below and to the left of the trigger var top = rect.bottom + 4; var left = rect.right - menuWidth; // Keep within viewport if (left < 8) left = 8; if (top + menuHeight > window.innerHeight - 8) { top = rect.top - menuHeight - 4; } if (top < 8) top = 8; menu.style.top = top + 'px'; menu.style.left = left + 'px'; // Delegated click handler on the flyout menu.addEventListener('click', _handleFlyoutClick); // Close on click-outside (next tick to avoid the opening click) setTimeout(function() { document.addEventListener('click', _flyoutOutsideClickHandler, true); }, 0); } /** * Close the flyout menu and any open submenu. */ function closeFlyoutMenu() { if (_flyoutSubmenuEl) { _flyoutSubmenuEl.remove(); _flyoutSubmenuEl = null; } if (_flyoutMenuEl) { _flyoutMenuEl.removeEventListener('click', _handleFlyoutClick); _flyoutMenuEl.remove(); _flyoutMenuEl = null; } // Remove mobile sheet if open var sheet = document.querySelector('.flyout-sheet'); if (sheet) sheet.remove(); document.removeEventListener('click', _flyoutOutsideClickHandler, true); _flyoutSessionKey = null; _flyoutSessionName = null; _flyoutRemoteId = null; } /** * Click-outside handler for the flyout menu. * @param {MouseEvent} e */ function _flyoutOutsideClickHandler(e) { if (_flyoutMenuEl && !_flyoutMenuEl.contains(e.target) && (!_flyoutSubmenuEl || !_flyoutSubmenuEl.contains(e.target))) { closeFlyoutMenu(); } } ``` Change 3 — Remove the old `.tile-delete` delegated handler. In `bindStaticEventListeners()` (lines 2185–2194), **replace** the entire block: ```javascript // Before (lines 2184-2194): // Delegated kill-session handler (tiles + sidebar items are re-rendered each poll) document.addEventListener('click', function(e) { var deleteBtn = e.target.closest && e.target.closest('.tile-delete, .sidebar-delete'); if (!deleteBtn) return; e.stopPropagation(); var name = deleteBtn.dataset.session; // Walk up to the tile/sidebar-item to get remoteId for federation routing var container = deleteBtn.closest('[data-remote-id]'); var remoteId = container ? container.dataset.remoteId : ''; if (name) killSession(name, remoteId); }); // After: // Delegated ⋮ options button handler (tiles are re-rendered each poll) document.addEventListener('click', function(e) { var optionsBtn = e.target.closest && e.target.closest('.tile-options-btn'); if (!optionsBtn) return; e.stopPropagation(); openFlyoutMenu(optionsBtn); }); ``` Change 4 — Export the new functions in `window.MuxplexApp` (after `killSession,`): ```javascript // Flyout menu openFlyoutMenu, closeFlyoutMenu, ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "flyout_menu or tile_delete_handler" -v ``` Expected: All 5 new tests PASS **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: add flyout menu open/close/position base, replace old tile-delete handler" ``` --- ## Task 4: Context-Dependent Menu Items — Data Map **Files:** - Modify: `muxplex/frontend/app.js` - Test: `muxplex/tests/test_frontend_js.py` Define `FLYOUT_MENU_MAP` — a data map keyed by view type (`all`, `user`, `hidden`) that returns the menu item configuration for each context. Implement `_buildFlyoutMenuItems()` which reads `_activeView` and the map to generate HTML. **Step 1: Write the failing tests** Add to `muxplex/tests/test_frontend_js.py`: ```python def test_flyout_menu_map_exists() -> None: """app.js must define a FLYOUT_MENU_MAP data structure.""" assert "FLYOUT_MENU_MAP" in _JS, ( "app.js must contain a FLYOUT_MENU_MAP data structure" ) def test_flyout_menu_map_has_three_view_types() -> None: """FLYOUT_MENU_MAP must have keys for 'all', 'user', and 'hidden'.""" # The map should reference all three view types map_section = _JS.split("FLYOUT_MENU_MAP")[1].split("};")[0] assert "'all'" in map_section or '"all"' in map_section, ( "FLYOUT_MENU_MAP must include an 'all' key" ) assert "'user'" in map_section or '"user"' in map_section, ( "FLYOUT_MENU_MAP must include a 'user' key" ) assert "'hidden'" in map_section or '"hidden"' in map_section, ( "FLYOUT_MENU_MAP must include a 'hidden' key" ) def test_build_flyout_menu_items_function_exists() -> None: """app.js must define a _buildFlyoutMenuItems function.""" assert "function _buildFlyoutMenuItems" in _JS, ( "app.js must contain a _buildFlyoutMenuItems function" ) def test_build_flyout_uses_menu_map() -> None: """_buildFlyoutMenuItems must reference FLYOUT_MENU_MAP.""" fn_body = _JS.split("function _buildFlyoutMenuItems")[1].split("\nfunction ")[0] assert "FLYOUT_MENU_MAP" in fn_body, ( "_buildFlyoutMenuItems must reference FLYOUT_MENU_MAP (data-driven, not if/else)" ) ``` **Step 2: Run tests to verify they fail** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_flyout_menu_map_exists -v ``` Expected: FAIL — `FLYOUT_MENU_MAP` not in JS **Step 3: Add the data map and builder to `muxplex/frontend/app.js`** Insert after the flyout state variables (after `_flyoutRemoteId`): ```javascript /** * Data map of menu item definitions keyed by view type. * Each entry is an array of item config objects with: * { label, action, className?, separator? } * The 'user' view type gets the active view name injected at render time. */ var FLYOUT_MENU_MAP = { 'all': [ { label: 'Add to View\u2026', action: 'add-to-view', className: 'flyout-menu__item--has-submenu' }, { label: 'Hide', action: 'hide' }, { separator: true }, { label: 'Kill Session', action: 'kill', className: 'flyout-menu__item--danger' }, ], 'user': [ { label: 'Add to View\u2026', action: 'add-to-view', className: 'flyout-menu__item--has-submenu' }, { label: 'Remove from {viewName}', action: 'remove-from-view' }, { label: 'Hide', action: 'hide' }, { separator: true }, { label: 'Kill Session', action: 'kill', className: 'flyout-menu__item--danger' }, ], 'hidden': [ { label: 'Unhide', action: 'unhide' }, { label: 'Unhide & Add to View\u2026', action: 'unhide-add-to-view', className: 'flyout-menu__item--has-submenu' }, { separator: true }, { label: 'Kill Session', action: 'kill', className: 'flyout-menu__item--danger' }, ], }; /** * Build the flyout menu HTML string based on the active view type. * Uses FLYOUT_MENU_MAP to generate items — no if/else chains. * @returns {string} HTML for the menu items */ function _buildFlyoutMenuItems() { // Determine view type: 'all', 'hidden', or 'user' var viewType = _activeView; if (viewType !== 'all' && viewType !== 'hidden') { viewType = 'user'; } var items = FLYOUT_MENU_MAP[viewType] || FLYOUT_MENU_MAP['all']; var html = ''; for (var i = 0; i < items.length; i++) { var item = items[i]; if (item.separator) { html += '
'; continue; } var label = item.label; // Inject view name for "Remove from {viewName}" if (label.indexOf('{viewName}') !== -1) { var displayName = _activeView; var fullName = _activeView; if (displayName.length > 20) { displayName = displayName.substring(0, 20) + '\u2026'; } label = label.replace('{viewName}', escapeHtml(displayName)); } var cls = 'flyout-menu__item'; if (item.className) cls += ' ' + item.className; var titleAttr = ''; if (item.action === 'remove-from-view' && _activeView && _activeView.length > 20) { titleAttr = ' title="Remove from ' + escapeHtml(_activeView) + '"'; } html += ''; } return html; } ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "flyout_menu_map or build_flyout" -v ``` Expected: All 4 new tests PASS **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: add FLYOUT_MENU_MAP data map and _buildFlyoutMenuItems builder" ``` --- ## Task 5: Flyout Click Handler — Dispatch Actions + "Add to View" Submenu **Files:** - Modify: `muxplex/frontend/app.js` - Test: `muxplex/tests/test_frontend_js.py` Implement `_handleFlyoutClick(e)` that dispatches to the correct action based on `data-action`. Implement `_openFlyoutSubmenu(triggerItem)` for the "Add to View" submenu with checkmark toggles. **Step 1: Write the failing tests** Add to `muxplex/tests/test_frontend_js.py`: ```python def test_handle_flyout_click_function_exists() -> None: """app.js must define a _handleFlyoutClick function.""" assert "function _handleFlyoutClick" in _JS, ( "app.js must contain a _handleFlyoutClick function" ) def test_handle_flyout_click_dispatches_actions() -> None: """_handleFlyoutClick must check data-action for dispatching.""" fn_body = _JS.split("function _handleFlyoutClick")[1].split("\nfunction ")[0] assert "data-action" in fn_body or "dataset.action" in fn_body, ( "_handleFlyoutClick must read data-action from the clicked element" ) def test_open_flyout_submenu_function_exists() -> None: """app.js must define a _openFlyoutSubmenu function.""" assert "function _openFlyoutSubmenu" in _JS, ( "app.js must contain a _openFlyoutSubmenu function" ) def test_submenu_toggles_view_membership() -> None: """_openFlyoutSubmenu must PATCH /api/settings to toggle view membership.""" fn_body = _JS.split("function _openFlyoutSubmenu")[1].split("\nfunction ")[0] assert "views" in fn_body and ("PATCH" in fn_body or "api(" in fn_body), ( "_openFlyoutSubmenu must PATCH /api/settings to add/remove session from view" ) ``` **Step 2: Run tests to verify they fail** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_handle_flyout_click_function_exists -v ``` Expected: FAIL **Step 3: Add the functions to `muxplex/frontend/app.js`** Insert after `_buildFlyoutMenuItems()`: ```javascript /** * Delegated click handler for the flyout menu. * Dispatches based on data-action attribute. * @param {MouseEvent} e */ function _handleFlyoutClick(e) { var item = e.target.closest('[data-action]'); if (!item) return; var action = item.dataset.action; switch (action) { case 'add-to-view': case 'unhide-add-to-view': _openFlyoutSubmenu(item, action === 'unhide-add-to-view'); break; case 'remove-from-view': _doRemoveFromView(); break; case 'hide': _doHideSession(); break; case 'unhide': _doUnhideSession(); break; case 'kill': _doKillSessionInline(item); break; default: break; } } /** * Open the "Add to View" submenu next to a flyout menu item. * Lists all user-created views with checkmarks for views the session is already in. * Clicking a view toggles membership immediately via PATCH /api/settings. * The flyout stays open after submenu actions. * @param {HTMLElement} triggerItem - The menu item that triggered the submenu * @param {boolean} unhideFirst - If true, also unhide the session (for "Unhide & Add to View") */ function _openFlyoutSubmenu(triggerItem, unhideFirst) { // Close existing submenu if (_flyoutSubmenuEl) { _flyoutSubmenuEl.remove(); _flyoutSubmenuEl = null; } var views = (_serverSettings && _serverSettings.views) || []; if (views.length === 0) { showToast('No user views. Create one from the header dropdown.'); return; } var sessionKey = _flyoutSessionKey; var html = ''; for (var i = 0; i < views.length; i++) { var v = views[i]; var isIn = (v.sessions || []).indexOf(sessionKey) !== -1; html += ''; } var submenu = document.createElement('div'); submenu.className = 'flyout-submenu'; submenu.setAttribute('role', 'menu'); submenu.innerHTML = html; document.body.appendChild(submenu); _flyoutSubmenuEl = submenu; // Position to the right of the trigger item (or left if no space) if (_flyoutMenuEl) { var menuRect = _flyoutMenuEl.getBoundingClientRect(); var subWidth = submenu.offsetWidth; var subHeight = submenu.offsetHeight; var itemRect = triggerItem.getBoundingClientRect(); var left = menuRect.right + 4; if (left + subWidth > window.innerWidth - 8) { left = menuRect.left - subWidth - 4; } var top = itemRect.top; if (top + subHeight > window.innerHeight - 8) { top = window.innerHeight - subHeight - 8; } if (top < 8) top = 8; submenu.style.top = top + 'px'; submenu.style.left = left + 'px'; } // Click handler for submenu items submenu.addEventListener('click', function(e) { var btn = e.target.closest('[data-view-index]'); if (!btn) return; var idx = parseInt(btn.dataset.viewIndex, 10); _toggleViewMembership(idx, sessionKey, unhideFirst); }); } /** * Toggle a session's membership in a view. * @param {number} viewIndex - Index in the views array * @param {string} sessionKey - The session key to toggle * @param {boolean} unhideFirst - If true, also remove from hidden_sessions */ function _toggleViewMembership(viewIndex, sessionKey, unhideFirst) { var views = (_serverSettings && _serverSettings.views) || []; var updatedViews = JSON.parse(JSON.stringify(views)); var view = updatedViews[viewIndex]; if (!view) return; var sessions = view.sessions || []; var idx = sessions.indexOf(sessionKey); if (idx !== -1) { // Remove from view sessions.splice(idx, 1); } else { // Add to view sessions.push(sessionKey); } view.sessions = sessions; var patch = { views: updatedViews }; // If unhiding, also update hidden_sessions if (unhideFirst) { var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; var hiddenIdx = hidden.indexOf(sessionKey); if (hiddenIdx !== -1) { var updatedHidden = hidden.slice(); updatedHidden.splice(hiddenIdx, 1); patch.hidden_sessions = updatedHidden; } } api('PATCH', '/api/settings', patch) .then(function() { if (_serverSettings) { _serverSettings.views = updatedViews; if (patch.hidden_sessions) _serverSettings.hidden_sessions = patch.hidden_sessions; } // Re-render the submenu to update checkmarks if (_flyoutSubmenuEl) { var checkItems = _flyoutSubmenuEl.querySelectorAll('[data-view-index]'); for (var i = 0; i < checkItems.length; i++) { var vi = parseInt(checkItems[i].dataset.viewIndex, 10); var checkEl = checkItems[i].querySelector('.flyout-submenu__check'); if (checkEl && updatedViews[vi]) { checkEl.textContent = (updatedViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? '\u2713' : ''; } } } // Refresh grid if needed (unhiding changes visible sessions) if (unhideFirst) { renderGrid(_currentSessions || []); } }) .catch(function(err) { showToast('Couldn\u2019t save \u2014 try again'); console.warn('[_toggleViewMembership] PATCH failed:', err); }); } ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "handle_flyout_click or open_flyout_submenu or submenu_toggles" -v ``` Expected: All 4 new tests PASS **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: add flyout click handler, Add to View submenu with toggle" ``` --- ## Task 6: Hide/Unhide/Remove Actions **Files:** - Modify: `muxplex/frontend/app.js` - Test: `muxplex/tests/test_frontend_js.py` Implement `_doHideSession()`, `_doUnhideSession()`, and `_doRemoveFromView()`. These are the non-kill flyout actions. **Step 1: Write the failing tests** Add to `muxplex/tests/test_frontend_js.py`: ```python def test_do_hide_session_function_exists() -> None: """app.js must define a _doHideSession function.""" assert "function _doHideSession" in _JS, ( "app.js must contain a _doHideSession function" ) def test_do_unhide_session_function_exists() -> None: """app.js must define a _doUnhideSession function.""" assert "function _doUnhideSession" in _JS, ( "app.js must contain a _doUnhideSession function" ) def test_do_remove_from_view_function_exists() -> None: """app.js must define a _doRemoveFromView function.""" assert "function _doRemoveFromView" in _JS, ( "app.js must contain a _doRemoveFromView function" ) def test_hide_session_removes_from_all_views() -> None: """_doHideSession must update both hidden_sessions AND views (remove from all views).""" fn_body = _JS.split("function _doHideSession")[1].split("\nfunction ")[0] assert "hidden_sessions" in fn_body, ( "_doHideSession must add session to hidden_sessions" ) assert "views" in fn_body, ( "_doHideSession must remove session from all views (mutual exclusion)" ) ``` **Step 2: Run tests to verify they fail** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_do_hide_session_function_exists -v ``` Expected: FAIL **Step 3: Add the functions to `muxplex/frontend/app.js`** Insert after `_toggleViewMembership()`: ```javascript /** * Hide a session: add to hidden_sessions and remove from ALL views. * Closes the flyout and re-renders the grid. */ function _doHideSession() { var sessionKey = _flyoutSessionKey; if (!sessionKey) return; var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; var views = (_serverSettings && _serverSettings.views) || []; // Add to hidden_sessions var updatedHidden = hidden.slice(); if (updatedHidden.indexOf(sessionKey) === -1) { updatedHidden.push(sessionKey); } // Remove from all views (mutual exclusion) var updatedViews = JSON.parse(JSON.stringify(views)); for (var i = 0; i < updatedViews.length; i++) { var sessions = updatedViews[i].sessions || []; var idx = sessions.indexOf(sessionKey); if (idx !== -1) sessions.splice(idx, 1); } closeFlyoutMenu(); api('PATCH', '/api/settings', { hidden_sessions: updatedHidden, views: updatedViews }) .then(function() { if (_serverSettings) { _serverSettings.hidden_sessions = updatedHidden; _serverSettings.views = updatedViews; } renderGrid(_currentSessions || []); renderViewDropdown(); }) .catch(function(err) { showToast('Couldn\u2019t save \u2014 try again'); console.warn('[_doHideSession] PATCH failed:', err); }); } /** * Unhide a session: remove from hidden_sessions. * Closes the flyout and re-renders the grid. */ function _doUnhideSession() { var sessionKey = _flyoutSessionKey; if (!sessionKey) return; var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; var idx = hidden.indexOf(sessionKey); if (idx === -1) { closeFlyoutMenu(); return; } var updatedHidden = hidden.slice(); updatedHidden.splice(idx, 1); closeFlyoutMenu(); api('PATCH', '/api/settings', { hidden_sessions: updatedHidden }) .then(function() { if (_serverSettings) _serverSettings.hidden_sessions = updatedHidden; renderGrid(_currentSessions || []); renderViewDropdown(); }) .catch(function(err) { showToast('Couldn\u2019t save \u2014 try again'); console.warn('[_doUnhideSession] PATCH failed:', err); }); } /** * Remove a session from the currently active user view. * Closes the flyout and re-renders the grid. */ function _doRemoveFromView() { var sessionKey = _flyoutSessionKey; if (!sessionKey || _activeView === 'all' || _activeView === 'hidden') return; var views = (_serverSettings && _serverSettings.views) || []; var updatedViews = JSON.parse(JSON.stringify(views)); // Find the active view and remove the session for (var i = 0; i < updatedViews.length; i++) { if (updatedViews[i].name === _activeView) { var sessions = updatedViews[i].sessions || []; var idx = sessions.indexOf(sessionKey); if (idx !== -1) sessions.splice(idx, 1); break; } } closeFlyoutMenu(); api('PATCH', '/api/settings', { views: updatedViews }) .then(function() { if (_serverSettings) _serverSettings.views = updatedViews; renderGrid(_currentSessions || []); }) .catch(function(err) { showToast('Couldn\u2019t save \u2014 try again'); console.warn('[_doRemoveFromView] PATCH failed:', err); }); } ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "do_hide or do_unhide or do_remove_from_view" -v ``` Expected: All 4 new tests PASS **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: add hide, unhide, and remove-from-view flyout actions" ``` --- ## Task 7: Kill Session Inline Confirmation **Files:** - Modify: `muxplex/frontend/app.js` - Test: `muxplex/tests/test_frontend_js.py` Replace the old `confirm()` dialog with inline confirmation inside the flyout: clicking "Kill Session" replaces the item with "Kill? [Yes] [No]". On error shows "Failed" for 2 seconds. On success closes menu. Also update `killSession()` to no longer use `confirm()`. **Step 1: Write the failing tests** Add to `muxplex/tests/test_frontend_js.py`: ```python def test_do_kill_session_inline_function_exists() -> None: """app.js must define a _doKillSessionInline function.""" assert "function _doKillSessionInline" in _JS, ( "app.js must contain a _doKillSessionInline function" ) def test_kill_session_no_confirm_dialog() -> None: """killSession must NOT use window.confirm() (replaced by inline confirmation).""" fn_body = _JS.split("function killSession")[1].split("\nfunction ")[0] assert "confirm(" not in fn_body, ( "killSession must not use confirm() — replaced by inline flyout confirmation" ) def test_do_kill_inline_shows_confirmation_buttons() -> None: """_doKillSessionInline must render Yes/No confirmation buttons.""" fn_body = _JS.split("function _doKillSessionInline")[1].split("\nfunction ")[0] assert "Yes" in fn_body and "No" in fn_body, ( "_doKillSessionInline must show 'Kill? [Yes] [No]' inline" ) ``` **Step 2: Run tests to verify they fail** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_do_kill_session_inline_function_exists -v ``` Expected: FAIL **Step 3: Apply the changes to `muxplex/frontend/app.js`** Change 1 — Add `_doKillSessionInline()` after `_doRemoveFromView()`: ```javascript /** * Show inline kill confirmation inside the flyout menu. * Replaces the "Kill Session" item with "Kill? [Yes] [No]". * No timeout — stays until click-outside closes the menu. * On error: "Failed" for 2 seconds then reverts. * @param {HTMLElement} killItem - The "Kill Session" menu item element */ function _doKillSessionInline(killItem) { var sessionName = _flyoutSessionName; var remoteId = _flyoutRemoteId; // Replace the kill item with confirmation UI var confirmHtml = ''; killItem.outerHTML = confirmHtml; // Re-attach handlers on the confirm/cancel buttons if (!_flyoutMenuEl) return; var confirmBtn = _flyoutMenuEl.querySelector('[data-action="confirm-kill"]'); var cancelBtn = _flyoutMenuEl.querySelector('[data-action="cancel-kill"]'); if (confirmBtn) { confirmBtn.addEventListener('click', function(e) { e.stopPropagation(); _executeKill(sessionName, remoteId); }); } if (cancelBtn) { cancelBtn.addEventListener('click', function(e) { e.stopPropagation(); closeFlyoutMenu(); }); } } /** * Execute the kill session API call from the flyout inline confirmation. * On success: closes flyout, shows toast, refreshes sessions. * On error: shows "Failed" for 2s in the confirm area, then reverts. * @param {string} name * @param {string} remoteId */ function _executeKill(name, remoteId) { var endpoint = remoteId ? '/api/federation/' + encodeURIComponent(remoteId) + '/sessions/' + encodeURIComponent(name) : '/api/sessions/' + encodeURIComponent(name); api('DELETE', endpoint) .then(function() { closeFlyoutMenu(); showToast('Session \'' + name + '\' killed'); if (_viewingSession === name) { closeSession(); } pollSessions(); }) .catch(function(err) { // Show "Failed" for 2 seconds var confirmDiv = _flyoutMenuEl && _flyoutMenuEl.querySelector('.flyout-menu__confirm'); if (confirmDiv) { confirmDiv.innerHTML = 'Failed'; setTimeout(function() { // Revert to original kill button if menu is still open if (_flyoutMenuEl && confirmDiv.parentNode) { confirmDiv.outerHTML = ''; } }, 2000); } }); } ``` Change 2 — Update `killSession()` (line 2160) to remove the `confirm()` call. It is now only used internally (sidebar kill still works through it): ```javascript // Before (line 2160-2161): function killSession(name, remoteId) { if (!confirm('Kill session "' + name + '"?')) return; // After: function killSession(name, remoteId) { ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "kill_session_inline or kill_session_no_confirm" -v ``` Expected: All 3 new tests PASS **Step 5: Commit** ```bash cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: add inline kill confirmation in flyout, remove old confirm() dialog" ``` --- ## Task 8: Add Sessions Panel — HTML + CSS **Files:** - Modify: `muxplex/frontend/index.html` - Modify: `muxplex/frontend/style.css` - Test: `muxplex/tests/test_frontend_html.py` - Test: `muxplex/tests/test_frontend_css.py` Add the Add Sessions panel overlay HTML and CSS. The panel shows all sessions NOT in the active user view, with immediate-commit checkboxes. **Step 1: Write the failing tests** Add to `muxplex/tests/test_frontend_html.py`: ```python def test_add_sessions_panel_exists() -> None: """index.html must contain an add-sessions-panel element.""" soup = _SOUP assert soup.find(id="add-sessions-panel"), ( "index.html must contain an element with id='add-sessions-panel'" ) def test_add_sessions_panel_has_role_dialog() -> None: """add-sessions-panel must have role='dialog' and aria-modal='true'.""" soup = _SOUP panel = soup.find(id="add-sessions-panel") assert panel, "Missing #add-sessions-panel" assert panel.get("role") == "dialog", ( "add-sessions-panel must have role='dialog'" ) assert panel.get("aria-modal") == "true", ( "add-sessions-panel must have aria-modal='true'" ) ``` Add to `muxplex/tests/test_frontend_css.py`: ```python def test_add_sessions_panel_styled() -> None: """style.css must contain .add-sessions-panel styles.""" css = read_css() assert ".add-sessions-panel" in css, "style.css must style .add-sessions-panel" def test_add_sessions_item_styled() -> None: """style.css must contain .add-sessions-item styles.""" css = read_css() assert ".add-sessions-item" in css, "style.css must style .add-sessions-item" ``` **Step 2: Run tests to verify they fail** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py::test_add_sessions_panel_exists muxplex/tests/test_frontend_css.py::test_add_sessions_panel_styled -v ``` Expected: FAIL **Step 3: Add HTML to `muxplex/frontend/index.html`** Insert after the bottom-sheet div (after line 74, before the session-pill button): ```html ``` **Step 4: Add CSS to `muxplex/frontend/style.css`** Append after the flyout sheet styles: ```css /* —— Add Sessions Panel ——————————————————————————————————————————————— */ .add-sessions-panel { position: fixed; inset: 0; z-index: 250; display: flex; align-items: center; justify-content: center; } .add-sessions-panel__backdrop { position: absolute; inset: 0; background: var(--bg-overlay); } .add-sessions-panel__content { position: relative; width: 90%; max-width: 440px; max-height: 70vh; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; } .add-sessions-panel__header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); } .add-sessions-panel__title { font-size: 15px; font-weight: 600; color: var(--text); margin: 0; } .add-sessions-panel__close { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; border-radius: 4px; } .add-sessions-panel__close:hover { background: var(--bg-surface); color: var(--text); } .add-sessions-panel__list { overflow-y: auto; padding: 8px 0; flex: 1; } .add-sessions-panel__empty { padding: 24px 16px; text-align: center; color: var(--text-muted); font-size: 13px; } .add-sessions-item { display: flex; align-items: center; gap: 10px; padding: 8px 16px; cursor: pointer; } .add-sessions-item:hover { background: var(--bg-surface); } .add-sessions-item--hidden { opacity: 0.5; } .add-sessions-item__checkbox { flex-shrink: 0; accent-color: var(--accent); } .add-sessions-item__name { font-size: 13px; color: var(--text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .add-sessions-item__device { font-size: 11px; color: var(--text-dim); white-space: nowrap; } .add-sessions-item__badge { font-size: 10px; color: var(--text-dim); background: var(--bg); border: 1px solid var(--border-subtle); border-radius: 3px; padding: 1px 5px; } .add-sessions-item__disclosure { width: 100%; padding: 2px 16px 6px 42px; font-size: 11px; color: var(--text-dim); font-style: italic; display: none; } /* Mobile: full-screen sheet for Add Sessions */ @media (max-width: 599px) { .add-sessions-panel__content { width: 100%; max-width: 100%; max-height: 100%; height: 100%; border-radius: 0; } } ``` **Step 5: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py -k "add_sessions" muxplex/tests/test_frontend_css.py -k "add_sessions" -v ``` Expected: All 4 new tests PASS **Step 6: Commit** ```bash cd muxplex && git add muxplex/frontend/index.html muxplex/frontend/style.css muxplex/tests/test_frontend_html.py muxplex/tests/test_frontend_css.py && git commit -m "feat: add Add Sessions panel HTML and CSS" ``` --- ## Task 9: Add Sessions Panel — JS Logic **Files:** - Modify: `muxplex/frontend/app.js` - Test: `muxplex/tests/test_frontend_js.py` Implement `openAddSessionsPanel()`, `closeAddSessionsPanel()`, and `renderAddSessionsList()`. The panel shows sessions not in the active view with immediate-commit checkboxes. **Step 1: Write the failing tests** Add to `muxplex/tests/test_frontend_js.py`: ```python def test_open_add_sessions_panel_function_exists() -> None: """app.js must define an openAddSessionsPanel function.""" assert "function openAddSessionsPanel" in _JS, ( "app.js must contain an openAddSessionsPanel function" ) def test_close_add_sessions_panel_function_exists() -> None: """app.js must define a closeAddSessionsPanel function.""" assert "function closeAddSessionsPanel" in _JS, ( "app.js must contain a closeAddSessionsPanel function" ) def test_render_add_sessions_list_function_exists() -> None: """app.js must define a renderAddSessionsList function.""" assert "function renderAddSessionsList" in _JS, ( "app.js must contain a renderAddSessionsList function" ) def test_add_sessions_uses_immediate_commit() -> None: """renderAddSessionsList must PATCH immediately on checkbox change (no batch Done).""" fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0] assert "PATCH" in fn_body or "api(" in fn_body, ( "renderAddSessionsList must fire PATCH on each checkbox change (immediate commit)" ) def test_add_sessions_shows_device_name() -> None: """renderAddSessionsList must show device name next to each session.""" fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0] assert "deviceName" in fn_body or "device" in fn_body, ( "renderAddSessionsList must show device name for disambiguation" ) ``` **Step 2: Run tests to verify they fail** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_open_add_sessions_panel_function_exists -v ``` Expected: FAIL **Step 3: Add the functions to `muxplex/frontend/app.js`** Insert after `_executeKill()`: ```javascript // ── Add Sessions Panel ─────────────────────────────────────────────────────── /** * Open the Add Sessions panel for the active user view. * Only available for user views (not "All" or "Hidden"). */ function openAddSessionsPanel() { if (_activeView === 'all' || _activeView === 'hidden') return; var panel = $('add-sessions-panel'); if (!panel) return; // Update title var titleEl = $('add-sessions-title'); if (titleEl) titleEl.textContent = 'Add Sessions to \u201c' + _activeView + '\u201d'; renderAddSessionsList(); panel.classList.remove('hidden'); // Close on backdrop click var backdrop = $('add-sessions-backdrop'); if (backdrop) { backdrop.onclick = closeAddSessionsPanel; } // Close button var closeBtn = $('add-sessions-close'); if (closeBtn) { closeBtn.onclick = closeAddSessionsPanel; } } /** * Close the Add Sessions panel. */ function closeAddSessionsPanel() { var panel = $('add-sessions-panel'); if (panel) panel.classList.add('hidden'); } /** * Render the session list inside the Add Sessions panel. * Shows all sessions NOT currently in the active view. * Hidden sessions are shown dimmed with a "hidden" badge and disclosure text. * Grouped by device, alphabetical within each group. * Immediate-commit checkboxes — each change fires a PATCH immediately. */ function renderAddSessionsList() { var listEl = $('add-sessions-list'); var emptyEl = $('add-sessions-empty'); if (!listEl) return; var views = (_serverSettings && _serverSettings.views) || []; var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; // Find the active view's session list var activeViewObj = null; for (var i = 0; i < views.length; i++) { if (views[i].name === _activeView) { activeViewObj = views[i]; break; } } if (!activeViewObj) { listEl.innerHTML = ''; return; } var viewSessions = activeViewObj.sessions || []; // Get all real sessions (not status entries), excluding those already in the view var allSessions = (_currentSessions || []).filter(function(s) { return !s.status; }); var notInView = allSessions.filter(function(s) { var key = s.sessionKey || s.name; return viewSessions.indexOf(key) === -1; }); if (notInView.length === 0) { listEl.innerHTML = ''; if (emptyEl) emptyEl.style.display = ''; return; } if (emptyEl) emptyEl.style.display = 'none'; // Sort: group by deviceName, alphabetical within each group notInView.sort(function(a, b) { var da = (a.deviceName || '').toLowerCase(); var db = (b.deviceName || '').toLowerCase(); if (da !== db) return da < db ? -1 : 1; var na = (a.name || '').toLowerCase(); var nb = (b.name || '').toLowerCase(); return na < nb ? -1 : na > nb ? 1 : 0; }); var html = ''; for (var j = 0; j < notInView.length; j++) { var s = notInView[j]; var key = s.sessionKey || s.name; var isHidden = hidden.indexOf(key) !== -1 || hidden.indexOf(s.name) !== -1; var escapedName = escapeHtml(s.name || ''); var deviceName = escapeHtml(s.deviceName || ''); html += ''; if (isHidden) { html += '