70 KiB
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:
# ---------------------------------------------------------------------------
# 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
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:
/* —— 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
cd muxplex && python -m pytest muxplex/tests/test_frontend_css.py -k "flyout" -v
Expected: All 5 new tests PASS
Step 5: Commit
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(thebuildTileHTML()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:
# ---------------------------------------------------------------------------
# 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
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:
// Before (line 455):
`<button class="tile-delete" data-session="${escapedName}" aria-label="Kill session">×</button>` +
// After:
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">⋮</button>` +
Step 4: Run tests to verify they pass
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
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:
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
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):
// 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):
// ── 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:
// 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,):
// Flyout menu
openFlyoutMenu,
closeFlyoutMenu,
Step 4: Run tests to verify they pass
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
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:
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
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):
/**
* 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 += '<div class="flyout-menu__separator" role="separator"></div>';
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 += '<button class="' + cls + '" role="menuitem" data-action="' + item.action + '"' + titleAttr + '>';
html += label;
html += '</button>';
}
return html;
}
Step 4: Run tests to verify they pass
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
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:
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
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():
/**
* 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 += '<button class="flyout-submenu__item" role="menuitem" data-view-index="' + i + '">';
html += '<span class="flyout-submenu__check">' + (isIn ? '\u2713' : '') + '</span>';
html += escapeHtml(v.name);
html += '</button>';
}
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
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
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:
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
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():
/**
* 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
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
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:
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
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():
/**
* 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 =
'<div class="flyout-menu__confirm">' +
'<span>Kill?</span>' +
'<button class="flyout-menu__confirm-btn flyout-menu__confirm-btn--yes" data-action="confirm-kill">Yes</button>' +
'<button class="flyout-menu__confirm-btn" data-action="cancel-kill">No</button>' +
'</div>';
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 = '<span style="color:var(--err)">Failed</span>';
setTimeout(function() {
// Revert to original kill button if menu is still open
if (_flyoutMenuEl && confirmDiv.parentNode) {
confirmDiv.outerHTML =
'<button class="flyout-menu__item flyout-menu__item--danger" role="menuitem" data-action="kill">Kill Session</button>';
}
}, 2000);
}
});
}
Change 2 — Update killSession() (line 2160) to remove the confirm() call. It is now only used internally (sidebar kill still works through it):
// 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
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
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:
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:
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
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):
<!-- —— Add Sessions panel ———————————————————————————————————————————— -->
<div id="add-sessions-panel" class="add-sessions-panel hidden" role="dialog" aria-modal="true" aria-label="Add sessions to view">
<div class="add-sessions-panel__backdrop" id="add-sessions-backdrop"></div>
<div class="add-sessions-panel__content">
<div class="add-sessions-panel__header">
<h2 id="add-sessions-title" class="add-sessions-panel__title">Add Sessions</h2>
<button id="add-sessions-close" class="add-sessions-panel__close" aria-label="Close">×</button>
</div>
<div id="add-sessions-list" class="add-sessions-panel__list"></div>
<p id="add-sessions-empty" class="add-sessions-panel__empty" style="display:none">All sessions are already in this view.</p>
</div>
</div>
Step 4: Add CSS to muxplex/frontend/style.css
Append after the flyout sheet styles:
/* —— 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
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
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:
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
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():
// ── 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 += '<label class="add-sessions-item' + (isHidden ? ' add-sessions-item--hidden' : '') + '">';
html += '<input type="checkbox" class="add-sessions-item__checkbox" data-session-key="' + escapeHtml(key) + '"' + (isHidden ? ' data-is-hidden="1"' : '') + ' />';
html += '<span class="add-sessions-item__name">' + escapedName + '</span>';
if (deviceName) html += '<span class="add-sessions-item__device">' + deviceName + '</span>';
if (isHidden) html += '<span class="add-sessions-item__badge">hidden</span>';
html += '</label>';
if (isHidden) {
html += '<div class="add-sessions-item__disclosure">This will make it visible again.</div>';
}
}
listEl.innerHTML = html;
// Delegated change handler for immediate-commit checkboxes
listEl.onchange = function(e) {
var cb = e.target.closest('.add-sessions-item__checkbox');
if (!cb) return;
var sessionKey = cb.dataset.sessionKey;
var isHiddenSession = cb.dataset.isHidden === '1';
if (cb.checked) {
// Add to view (and unhide if hidden)
_addSessionToActiveView(sessionKey, isHiddenSession, cb);
} else {
// Should not normally happen (unchecking means removing), but handle gracefully
cb.checked = false;
}
};
// Show/hide disclosure on hover for hidden items
listEl.onmouseover = function(e) {
var item = e.target.closest('.add-sessions-item--hidden');
if (item) {
var disc = item.nextElementSibling;
if (disc && disc.classList.contains('add-sessions-item__disclosure')) {
disc.style.display = '';
}
}
};
listEl.onmouseout = function(e) {
var item = e.target.closest('.add-sessions-item--hidden');
if (item) {
var disc = item.nextElementSibling;
if (disc && disc.classList.contains('add-sessions-item__disclosure')) {
disc.style.display = 'none';
}
}
};
}
/**
* Add a session to the active user view via PATCH.
* If the session is hidden, also unhide it.
* On error: show toast, revert checkbox.
* @param {string} sessionKey
* @param {boolean} unhideFirst
* @param {HTMLInputElement} checkbox
*/
function _addSessionToActiveView(sessionKey, unhideFirst, checkbox) {
var views = (_serverSettings && _serverSettings.views) || [];
var updatedViews = JSON.parse(JSON.stringify(views));
// Find active view and add session
for (var i = 0; i < updatedViews.length; i++) {
if (updatedViews[i].name === _activeView) {
var sessions = updatedViews[i].sessions || [];
if (sessions.indexOf(sessionKey) === -1) {
sessions.push(sessionKey);
}
updatedViews[i].sessions = sessions;
break;
}
}
var patch = { views: updatedViews };
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 list (session is now in the view, so it disappears from the list)
renderAddSessionsList();
// Refresh the grid behind the panel
renderGrid(_currentSessions || []);
})
.catch(function(err) {
showToast('Couldn\u2019t save \u2014 try again');
if (checkbox) checkbox.checked = false;
console.warn('[_addSessionToActiveView] PATCH failed:', err);
});
}
Change 2 — Export the new functions in window.MuxplexApp:
// Add Sessions panel
openAddSessionsPanel,
closeAddSessionsPanel,
renderAddSessionsList,
Step 4: Run tests to verify they pass
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "add_sessions" -v
Expected: All 5 new tests PASS
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: add Add Sessions panel JS logic with immediate-commit checkboxes"
Task 10: Mobile Variants — Bottom Action Sheet + Full-Screen Panels
Files:
- Modify:
muxplex/frontend/app.js - Test:
muxplex/tests/test_frontend_js.py
On mobile (isMobile()), the ⋮ tap opens a bottom action sheet instead of a floating menu. "Add to View" on mobile opens a full-height picker sheet. The Add Sessions panel already handles mobile via the CSS @media rule from Task 8.
Step 1: Write the failing tests
Add to muxplex/tests/test_frontend_js.py:
def test_open_flyout_sheet_function_exists() -> None:
"""app.js must define a _openFlyoutSheet function for mobile."""
assert "function _openFlyoutSheet" in _JS, (
"app.js must contain a _openFlyoutSheet function for mobile bottom sheet"
)
def test_open_flyout_menu_checks_mobile() -> None:
"""openFlyoutMenu must check isMobile() to decide between flyout and sheet."""
fn_body = _JS.split("function openFlyoutMenu")[1].split("\nfunction ")[0]
assert "isMobile" in fn_body, (
"openFlyoutMenu must check isMobile() to branch between flyout and sheet"
)
Step 2: Run tests to verify they fail
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_open_flyout_sheet_function_exists -v
Expected: FAIL
Step 3: Add the mobile sheet function to muxplex/frontend/app.js
Insert after closeFlyoutMenu():
/**
* Open a bottom action sheet for the flyout menu (mobile).
* Same actions as the desktop flyout, but renders as a full-width bottom sheet.
*/
function _openFlyoutSheet() {
var viewType = _activeView;
if (viewType !== 'all' && viewType !== 'hidden') viewType = 'user';
var items = FLYOUT_MENU_MAP[viewType] || FLYOUT_MENU_MAP['all'];
var html = '<div class="flyout-sheet__backdrop"></div>';
html += '<div class="flyout-sheet__panel">';
html += '<div class="flyout-sheet__handle" aria-hidden="true"></div>';
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (item.separator) {
html += '<div class="flyout-sheet__separator"></div>';
continue;
}
var label = item.label;
if (label.indexOf('{viewName}') !== -1) {
var displayName = _activeView;
if (displayName.length > 20) displayName = displayName.substring(0, 20) + '\u2026';
label = label.replace('{viewName}', escapeHtml(displayName));
}
var cls = 'flyout-sheet__item';
if (item.className && item.className.indexOf('danger') !== -1) cls += ' flyout-sheet__item--danger';
html += '<button class="' + cls + '" data-action="' + item.action + '">';
html += label;
html += '</button>';
}
html += '</div>';
var sheet = document.createElement('div');
sheet.className = 'flyout-sheet';
sheet.setAttribute('role', 'dialog');
sheet.setAttribute('aria-modal', 'true');
sheet.innerHTML = html;
document.body.appendChild(sheet);
// Backdrop closes
var backdrop = sheet.querySelector('.flyout-sheet__backdrop');
if (backdrop) {
backdrop.addEventListener('click', closeFlyoutMenu);
}
// Delegated action handler
var panel = sheet.querySelector('.flyout-sheet__panel');
if (panel) {
panel.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.dataset.action;
if (action === 'add-to-view' || action === 'unhide-add-to-view') {
// On mobile, close sheet and open Add Sessions panel
closeFlyoutMenu();
openAddSessionsPanel();
} else if (action === 'kill') {
// Simple confirm on mobile (inline doesn't work well in sheets)
closeFlyoutMenu();
killSession(_flyoutSessionName, _flyoutRemoteId);
} else {
// Dispatch directly
_handleFlyoutClick(e);
}
});
}
}
Step 4: Run tests to verify they pass
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "flyout_sheet or open_flyout_menu_checks_mobile" -v
Expected: Both PASS
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: add mobile bottom action sheet for flyout menu"
Task 11: Wire Up "Add Sessions" Entry Point in Grid
Files:
- Modify:
muxplex/frontend/app.js(insiderenderGrid()) - Modify:
muxplex/frontend/style.css - Test:
muxplex/tests/test_frontend_js.py
When in a user-created view, show an "Add Sessions" affordance tile at the end of the grid that opens the Add Sessions panel. This gives users a discoverable entry point beyond the flyout submenu.
Step 1: Write the failing test
Add to muxplex/tests/test_frontend_js.py:
def test_render_grid_has_add_sessions_affordance() -> None:
"""renderGrid must include an 'Add Sessions' affordance when in a user view."""
fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
assert "add-sessions" in fn_body.lower() or "openAddSessionsPanel" in fn_body, (
"renderGrid must render an 'Add Sessions' affordance for user views"
)
Step 2: Run test to verify it fails
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_grid_has_add_sessions_affordance -v
Expected: FAIL
Step 3: Apply the changes to muxplex/frontend/app.js
In renderGrid(), after the grid HTML is assembled (right before grid.innerHTML = html;), add the "Add Sessions" affordance tile if in a user view:
// Add Sessions affordance tile — shown in user views only
if (_activeView !== 'all' && _activeView !== 'hidden') {
var viewsArr = (_serverSettings && _serverSettings.views) || [];
var isUserView = false;
for (var vi = 0; vi < viewsArr.length; vi++) {
if (viewsArr[vi].name === _activeView) { isUserView = true; break; }
}
if (isUserView) {
html += '<button class="add-sessions-tile" onclick="window.MuxplexApp.openAddSessionsPanel()" aria-label="Add sessions to this view">';
html += '<span class="add-sessions-tile__icon">+</span>';
html += '<span class="add-sessions-tile__label">Add Sessions</span>';
html += '</button>';
}
}
Add CSS to muxplex/frontend/style.css (after the Add Sessions panel styles):
/* —— Add Sessions affordance tile ————————————————————————————————————— */
.add-sessions-tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 120px;
background: transparent;
border: 2px dashed var(--border-subtle);
border-radius: 8px;
color: var(--text-dim);
font-size: 13px;
cursor: pointer;
transition: border-color var(--t-fast), color var(--t-fast);
}
.add-sessions-tile:hover {
border-color: var(--accent);
color: var(--accent);
}
.add-sessions-tile__icon {
font-size: 24px;
line-height: 1;
}
.add-sessions-tile__label {
font-size: 12px;
}
Step 4: Run test to verify it passes
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_grid_has_add_sessions_affordance -v
Expected: PASS
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/style.css muxplex/tests/test_frontend_js.py && git commit -m "feat: add 'Add Sessions' affordance tile in user views grid"
Task 12: Session Death Detection — Close Flyout if Session Dies
Files:
- Modify:
muxplex/frontend/app.js(the poll/render cycle) - Test:
muxplex/tests/test_frontend_js.py
If the session being targeted by the flyout dies (disappears from _currentSessions during a poll), close the flyout. This handles the edge case where a user has the kill confirmation showing and the session dies externally.
Step 1: Write the failing test
Add to muxplex/tests/test_frontend_js.py:
def test_render_grid_closes_stale_flyout() -> None:
"""renderGrid must close the flyout if the targeted session no longer exists."""
fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
assert "_flyoutSessionKey" in fn_body or "closeFlyoutMenu" in fn_body, (
"renderGrid must check if the flyout's target session still exists and close if not"
)
Step 2: Run test to verify it fails
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_grid_closes_stale_flyout -v
Expected: FAIL
Step 3: Apply the change to muxplex/frontend/app.js
In renderGrid(), at the beginning of the function (after the var grid = $('session-grid'); check), add:
// Close flyout if the targeted session no longer exists
if (_flyoutSessionKey) {
var flyoutStillExists = (sessions || []).some(function(s) {
return (s.sessionKey || s.name) === _flyoutSessionKey;
});
if (!flyoutStillExists) {
closeFlyoutMenu();
}
}
Step 4: Run test to verify it passes
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_grid_closes_stale_flyout -v
Expected: PASS
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: close flyout when targeted session disappears"
Task 13: Final Integration — Run Full Test Suite
Files: None (verification only)
Step 1: Run the complete test suite
cd muxplex && python -m pytest muxplex/tests/ -v --timeout=120
Expected: All tests PASS
Step 2: Run quality checks
cd muxplex && python -m ruff check muxplex/
cd muxplex && python -m ruff format --check muxplex/
Expected: No errors. Fix any formatting or lint issues.
Step 3: Verify the Phase 3 feature chain end-to-end
Check that the full chain works by listing what Phase 3 established:
- Flyout CSS —
.flyout-menu,.flyout-submenu,.flyout-sheet,.tile-options-btnall styled ✓ ⋮button on session tiles — replaces old.tile-deletebutton inbuildTileHTML()✓- Flyout base JS —
openFlyoutMenu()/closeFlyoutMenu()withposition:fixed+getBoundingClientRectpositioning ✓ - Context-dependent menu items —
FLYOUT_MENU_MAPdata map withall/user/hiddenkeys, no if/else ✓ - "Add to View" submenu —
_openFlyoutSubmenu()with checkmark toggle, immediate PATCH, flyout stays open ✓ - Hide/Unhide/Remove actions —
_doHideSession()(removes from all views + adds to hidden),_doUnhideSession(),_doRemoveFromView()✓ - Kill session inline confirmation —
_doKillSessionInline()replaces item with "Kill? [Yes] [No]", "Failed" for 2s on error ✓ - Add Sessions panel — HTML overlay with immediate-commit checkboxes, dimmed hidden sessions with badge, device names, alphabetical grouped by device, empty state message ✓
- Add Sessions panel JS —
openAddSessionsPanel()/renderAddSessionsList()with PATCH on each checkbox ✓ - Mobile variants —
_openFlyoutSheet()bottom action sheet, Add Sessions panel is full-screen via CSS media query ✓ - "Add Sessions" affordance tile — dashed-border tile in grid for user views ✓
- Session death detection — flyout closes if target session disappears during poll ✓
Step 4: Verify all three phases work together
Run the full test suite one more time to confirm no cross-phase regressions:
cd muxplex && python -m pytest muxplex/tests/ -v --timeout=180
Step 5: Commit any remaining fixes
cd muxplex && git add -A && git status
If there are uncommitted changes, commit them:
cd muxplex && git commit -m "chore: phase 3 integration fixes"
Summary: Phase 3 delivers 12 implementation tasks + 1 verification task
| Task | What | Files |
|---|---|---|
| 1 | Flyout CSS (menu, submenu, mobile sheet, trigger) | style.css, tests/test_frontend_css.py |
| 2 | ⋮ button on tiles, remove old .tile-delete |
app.js, tests/test_frontend_js.py |
| 3 | Flyout base JS (open, position, close, delegation) | app.js, tests/test_frontend_js.py |
| 4 | FLYOUT_MENU_MAP data map + _buildFlyoutMenuItems() |
app.js, tests/test_frontend_js.py |
| 5 | Click handler + "Add to View" submenu with toggle | app.js, tests/test_frontend_js.py |
| 6 | Hide, Unhide, Remove from View actions | app.js, tests/test_frontend_js.py |
| 7 | Kill session inline confirmation (replaces confirm()) |
app.js, tests/test_frontend_js.py |
| 8 | Add Sessions panel HTML + CSS | index.html, style.css, tests/test_frontend_html.py, tests/test_frontend_css.py |
| 9 | Add Sessions panel JS logic | app.js, tests/test_frontend_js.py |
| 10 | Mobile bottom action sheet for flyout | app.js, tests/test_frontend_js.py |
| 11 | "Add Sessions" affordance tile in grid | app.js, style.css, tests/test_frontend_js.py |
| 12 | Session death detection (close stale flyout) | app.js, tests/test_frontend_js.py |
| 13 | Final integration — full test suite | verification only |
After Phase 3, the Views feature is complete. Users can:
- Switch views via the header dropdown (Phase 2)
- Right-click
⋮on any tile to add/remove from views, hide/unhide, or kill (Phase 3) - Use the Add Sessions panel to bulk-add sessions to a view (Phase 3)
- All actions use immediate-commit PATCH with error recovery (Phase 3)
- Mobile gets bottom action sheets and full-screen panels (Phase 3)