` +
- `` +
``
);
}
@@ -886,24 +887,29 @@ function renderViewDropdown() {
var html = '';
- // — All Sessions (always first, shortcut 1)
+ // — All Sessions (always first) — show count of non-hidden sessions
+ var allHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || [];
+ var allCount = (_currentSessions || []).filter(function(s) {
+ if (s.status) return false;
+ return allHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && allHiddenSessions.indexOf(s.name) === -1;
+ }).length;
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
- html += '';
+ html += '';
- // — User views (shortcuts 2–8)
+ // — User views
if (views.length > 0) {
html += '
';
for (var i = 0; i < views.length && i < 7; i++) {
var v = views[i];
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
- html += '';
+ html += '';
}
}
- // — Hidden (N) (always last system view, shortcut 9)
+ // — Hidden (N) (always last system view)
html += '';
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
- html += '';
+ html += '';
// — Actions
html += '';
@@ -973,9 +979,14 @@ function renderSidebarViewDropdown() {
var html = '';
- // — All Sessions (always first)
+ // — All Sessions (always first) — show count of non-hidden sessions
+ var sbHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || [];
+ var sbAllCount = (_currentSessions || []).filter(function(s) {
+ if (s.status) return false;
+ return sbHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && sbHiddenSessions.indexOf(s.name) === -1;
+ }).length;
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
- html += '';
+ html += '';
// — User views
if (views.length > 0) {
@@ -983,14 +994,19 @@ function renderSidebarViewDropdown() {
for (var i = 0; i < views.length && i < 7; i++) {
var v = views[i];
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
- html += '';
+ html += '';
}
}
// — Hidden (N) (always last system view)
html += '';
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
- html += '';
+ html += '';
+
+ // — Actions: new view + manage views
+ html += '';
+ html += '';
+ html += '';
menu.innerHTML = html;
}
@@ -1084,6 +1100,7 @@ function showNewViewInput() {
.then(function() {
if (_serverSettings) _serverSettings.views = updatedViews;
switchView(name);
+ openAddSessionsPanel();
})
.catch(function() {
showToast('Failed to create view');
@@ -1102,6 +1119,91 @@ function showNewViewInput() {
});
}
+/**
+ * Show an inline text input inside the SIDEBAR view dropdown for creating a new view.
+ * Targets #sidebar-view-dropdown-menu instead of #view-dropdown-menu.
+ * - On Enter: validates, PATCHes /api/settings, calls switchView + openAddSessionsPanel.
+ * - On Escape / blur: closes the sidebar dropdown.
+ */
+function showSidebarNewViewInput() {
+ var menu = $('sidebar-view-dropdown-menu');
+ if (!menu) return;
+
+ // Re-focus existing input instead of creating a duplicate
+ var existing = menu.querySelector('.view-dropdown__new-input');
+ if (existing) {
+ existing.focus();
+ return;
+ }
+
+ // Find the '+ New View' button to replace
+ var newViewBtn = menu.querySelector('[data-action="new-view"]');
+ if (!newViewBtn) return;
+
+ // Create the inline text input
+ var input = document.createElement('input');
+ input.type = 'text';
+ input.className = 'view-dropdown__new-input';
+ input.placeholder = 'View name';
+ input.maxLength = 30;
+ input.setAttribute('aria-label', 'New view name');
+
+ // Replace the '+ New View' button with the input
+ newViewBtn.parentNode.replaceChild(input, newViewBtn);
+ input.focus();
+
+ function closeSidebarDropdown() {
+ menu.classList.add('hidden');
+ var trigger = $('sidebar-view-dropdown-trigger');
+ if (trigger) trigger.setAttribute('aria-expanded', 'false');
+ }
+
+ input.addEventListener('keydown', function(e) {
+ if (e.key === 'Enter') {
+ var name = input.value.trim();
+
+ // Validate: not empty
+ if (!name) return;
+
+ // Validate: not reserved (case-insensitive)
+ if (name.toLowerCase() === 'all' || name.toLowerCase() === 'hidden') {
+ showToast('Cannot use reserved name \'' + name + '\'');
+ return;
+ }
+
+ // Validate: not duplicate
+ var views = (_serverSettings && _serverSettings.views) || [];
+ if (views.find(function(v) { return v.name === name; })) {
+ showToast('View \'' + name + '\' already exists');
+ return;
+ }
+
+ // Create view and PATCH /api/settings
+ var updatedViews = views.concat([{ name: name, sessions: [] }]);
+ api('PATCH', '/api/settings', { views: updatedViews })
+ .then(function() {
+ if (_serverSettings) _serverSettings.views = updatedViews;
+ closeSidebarDropdown();
+ switchView(name);
+ openAddSessionsPanel();
+ })
+ .catch(function() {
+ showToast('Failed to create view');
+ });
+ } else if (e.key === 'Escape') {
+ closeSidebarDropdown();
+ }
+ });
+
+ input.addEventListener('blur', function() {
+ setTimeout(function() {
+ if (document.activeElement !== input) {
+ closeSidebarDropdown();
+ }
+ }, 150);
+ });
+}
+
/**
* Save updated views array via PATCH /api/settings, update _serverSettings,
* re-render the views settings tab, and re-render the view dropdown.
@@ -1372,6 +1474,8 @@ function switchView(viewName) {
sidebarLabel.textContent = viewName;
}
}
+ // Show/hide the header Add Sessions button
+ updateAddSessionsButton();
// Persist active view — fire and forget
api('PATCH', '/api/state', { active_view: viewName }).catch(function() {});
}
@@ -1438,21 +1542,6 @@ function renderGrid(sessions) {
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
});
- // 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 += '';
- }
- }
-
if (grid) grid.innerHTML = html + statusTilesHtml;
// Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped)
@@ -1922,10 +2011,6 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
}
var views = (_serverSettings && _serverSettings.views) || [];
- if (views.length === 0) {
- showToast('No user views. Create one from the header dropdown.');
- return;
- }
var sessionKey = _flyoutSessionKey;
// When in a user view, filter it out — the user already has "Remove from [ViewName]" for it
@@ -1940,6 +2025,11 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
html += escapeHtml(v.name);
html += '';
}
+ // — Always show "+ New View" option at the bottom
+ if (views.length > 0) {
+ html += '';
+ }
+ html += '';
var submenu = document.createElement('div');
submenu.className = 'flyout-submenu';
@@ -1971,6 +2061,50 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
// Click handler — toggle view membership via PATCH /api/settings
submenu.addEventListener('click', function(e) {
+ // Handle '+ New View' action
+ var newViewAction = e.target.closest('[data-action="new-view-in-flyout"]');
+ if (newViewAction) {
+ var capturedKey = sessionKey;
+ var capturedUnhide = unhideFirst;
+ closeFlyoutMenu();
+ var newName = prompt('View name:');
+ if (!newName || !newName.trim()) return;
+ newName = newName.trim();
+ if (newName.toLowerCase() === 'all' || newName.toLowerCase() === 'hidden') {
+ showToast('Cannot use reserved name \'' + newName + '\'');
+ return;
+ }
+ var existViews = (_serverSettings && _serverSettings.views) || [];
+ if (existViews.find(function(v) { return v.name === newName; })) {
+ showToast('View \'' + newName + '\' already exists');
+ return;
+ }
+ var newView = { name: newName, sessions: [capturedKey] };
+ var newViews = existViews.concat([newView]);
+ var flyoutPatch = { views: newViews };
+ if (capturedUnhide) {
+ var hiddenList = (_serverSettings && _serverSettings.hidden_sessions) || [];
+ var hi = hiddenList.indexOf(capturedKey);
+ if (hi !== -1) {
+ var updHidden = hiddenList.slice();
+ updHidden.splice(hi, 1);
+ flyoutPatch.hidden_sessions = updHidden;
+ }
+ }
+ api('PATCH', '/api/settings', flyoutPatch)
+ .then(function() {
+ if (_serverSettings) {
+ _serverSettings.views = newViews;
+ if (flyoutPatch.hidden_sessions) _serverSettings.hidden_sessions = flyoutPatch.hidden_sessions;
+ }
+ switchView(newName);
+ })
+ .catch(function() {
+ showToast('Failed to create view');
+ });
+ return;
+ }
+
var btn = e.target.closest('[data-view-index]');
if (!btn) return;
var idx = parseInt(btn.dataset.viewIndex, 10);
@@ -2252,6 +2386,28 @@ function closeAddSessionsPanel() {
if (panel) panel.classList.add('hidden');
}
+/**
+ * Show or hide the header #add-sessions-btn based on the active view.
+ * Visible only when in a user-created view (not 'all' or 'hidden').
+ * Called from switchView() and on initial load.
+ */
+function updateAddSessionsButton() {
+ var btn = $('add-sessions-btn');
+ if (!btn) return;
+ var isUserView = false;
+ if (_activeView !== 'all' && _activeView !== 'hidden') {
+ var views = (_serverSettings && _serverSettings.views) || [];
+ for (var i = 0; i < views.length; i++) {
+ if (views[i].name === _activeView) { isUserView = true; break; }
+ }
+ }
+ if (isUserView) {
+ btn.classList.remove('hidden');
+ } else {
+ btn.classList.add('hidden');
+ }
+}
+
/**
* Render the session list inside the Add Sessions panel.
* Shows all sessions NOT currently in the active view.
@@ -3763,10 +3919,28 @@ function bindStaticEventListeners() {
// Close sidebar dropdown after selection
sidebarViewMenu.classList.add('hidden');
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
+ return;
+ }
+ var action = e.target.closest('[data-action]');
+ if (action) {
+ if (action.dataset.action === 'new-view') {
+ showSidebarNewViewInput();
+ } else if (action.dataset.action === 'manage-views') {
+ sidebarViewMenu.classList.add('hidden');
+ if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
+ openSettings();
+ switchSettingsTab('views');
+ } else {
+ sidebarViewMenu.classList.add('hidden');
+ if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
+ }
}
});
}
+ var addSessionsBtn = $('add-sessions-btn');
+ if (addSessionsBtn) on(addSessionsBtn, 'click', openAddSessionsPanel);
+
// Click-outside closes the header view dropdown
document.addEventListener('click', function(e) {
var dropdown = $('view-dropdown-menu');
@@ -4104,6 +4278,7 @@ document.addEventListener('DOMContentLoaded', async function() {
updatePageTitle();
startHeartbeat();
bindStaticEventListeners();
+ updateAddSessionsButton();
})
.catch(function(err) {
console.error('[init] restoreState failed, retrying in 5s:', err);
@@ -4200,6 +4375,9 @@ if (typeof module !== 'undefined' && module.exports) {
// Sidebar view dropdown
renderSidebarViewDropdown,
toggleSidebarViewDropdown,
+ showSidebarNewViewInput,
+ // Add Sessions header button
+ updateAddSessionsButton,
// Manage Views settings tab
renderViewsSettingsTab,
_saveViewsAndRerender,
diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html
index 94cf2a6..5cc3766 100644
--- a/muxplex/frontend/index.html
+++ b/muxplex/frontend/index.html
@@ -27,6 +27,7 @@
+
diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css
index bacab42..a5a948a 100644
--- a/muxplex/frontend/style.css
+++ b/muxplex/frontend/style.css
@@ -208,6 +208,7 @@ body {
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
+ gap: 4px;
}
.tile-name {
@@ -2216,9 +2217,7 @@ body {
/* —— Tile Flyout Menu ————————————————————————————————————————————————————— */
.tile-options-btn {
- position: absolute;
- top: 6px;
- right: 6px;
+ flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
@@ -2231,7 +2230,6 @@ body {
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);
}
diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs
index c52747e..55c619a 100644
--- a/muxplex/frontend/tests/test_app.mjs
+++ b/muxplex/frontend/tests/test_app.mjs
@@ -2614,30 +2614,36 @@ test('buildTileHTML escapes HTML in deviceName within device-badge', () => {
// --- buildTileHTML device badge placement (task-3) ---
-test('buildTileHTML places device-badge inside tile-meta span', () => {
+test('buildTileHTML places device-badge inline in tile-header (before tile-meta)', () => {
app._setServerSettings({ multi_device_enabled: true });
const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' };
const html = app.buildTileHTML(session, 0, false);
- const tileMetaStart = html.indexOf('');
- // Note: finds the first after tileMetaStart, which is device-badge's closing tag
- // (not tile-meta's own close), but the assertion still holds because device-badge
- // opens and closes before tile-time within the tile-meta container.
- const tileMetaEnd = html.indexOf('', tileMetaStart);
- assert.ok(tileMetaStart !== -1, 'tile-meta span should exist');
+ // Badge is now a sibling flex item inside tile-header, after tile-name, before tile-meta/button
+ const tileHeaderStart = html.indexOf('
');
+ const tileHeaderEnd = html.indexOf('
', tileHeaderStart);
+ assert.ok(tileHeaderStart !== -1, 'tile-header div should exist');
const deviceBadgePos = html.indexOf('device-badge');
+ assert.ok(deviceBadgePos !== -1, 'device-badge should exist in tile HTML');
assert.ok(
- deviceBadgePos > tileMetaStart && deviceBadgePos < tileMetaEnd,
- `device-badge should be inside tile-meta span (tile-meta starts at ${tileMetaStart}, device-badge at ${deviceBadgePos}, tile-meta closes at ${tileMetaEnd})`
+ deviceBadgePos > tileHeaderStart && deviceBadgePos < tileHeaderEnd,
+ `device-badge should be inside tile-header (tile-header starts at ${tileHeaderStart}, device-badge at ${deviceBadgePos}, tile-header closes at ${tileHeaderEnd})`
);
app._setServerSettings(null);
});
-test('buildTileHTML includes tile-meta-sep with middle dot when badge present', () => {
+test('buildTileHTML badge and options-btn are siblings in tile-header when badge present', () => {
+ // Since the badge moved out of tile-meta into tile-header directly, tile-meta-sep is removed.
+ // The tile-options-btn is also now inside tile-header as a flex sibling.
app._setServerSettings({ multi_device_enabled: true });
const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' };
const html = app.buildTileHTML(session, 0, false);
- assert.ok(html.includes('tile-meta-sep'), 'should include tile-meta-sep element when badge is present');
- assert.ok(html.includes('\u00b7'), 'should include middle dot separator (\u00b7)');
+ // Badge is directly in tile-header (not inside tile-meta)
+ assert.ok(html.includes('device-badge'), 'device-badge should exist in tile HTML');
+ // tile-options-btn is also inside tile-header (before tile-body)
+ const headerStart = html.indexOf('
');
+ const bodyStart = html.indexOf('
');
+ const btnPos = html.indexOf('tile-options-btn');
+ assert.ok(btnPos > headerStart && btnPos < bodyStart, 'tile-options-btn should be inside tile-header (before tile-body)');
app._setServerSettings(null);
});
diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py
index 2361f9f..987209e 100644
--- a/muxplex/tests/test_frontend_js.py
+++ b/muxplex/tests/test_frontend_js.py
@@ -3948,10 +3948,22 @@ def test_open_flyout_menu_checks_mobile() -> None:
def test_render_grid_has_add_sessions_affordance() -> None:
- """renderGrid must include an 'Add Sessions' affordance when in a user view."""
+ """app.js must have an 'Add Sessions' affordance for user views.
+
+ The affordance moved from a tile in renderGrid to a header button (#add-sessions-btn)
+ managed by updateAddSessionsButton(). Either the old tile approach or the new header
+ button approach satisfies this requirement.
+ """
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"
+ # Old approach: tile in the grid | New approach: header button via updateAddSessionsButton
+ has_affordance = (
+ "add-sessions" in fn_body.lower()
+ or "openAddSessionsPanel" in fn_body
+ or "updateAddSessionsButton" in _JS
+ )
+ assert has_affordance, (
+ "app.js must have an 'Add Sessions' affordance for user views — "
+ "either as a tile in renderGrid or as a header button via updateAddSessionsButton"
)
@@ -4244,3 +4256,235 @@ def test_kill_confirm_buttons_use_role_button() -> None:
assert 'data-action="cancel" role="button"' in _JS, (
"Cancel button inside alertdialog must use role='button'"
)
+
+
+# ============================================================
+# UX Refinements from live testing (6 issues)
+# ============================================================
+
+CSS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "style.css"
+_CSS: str = CSS_PATH.read_text()
+
+
+# — Issue 1: Sidebar dropdown "+ New View" ——————————————————————————
+
+def test_render_sidebar_view_dropdown_has_new_view_action() -> None:
+ """renderSidebarViewDropdown must include a '+ New View' action button."""
+ match = re.search(
+ r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "renderSidebarViewDropdown function not found"
+ body = match.group(1)
+ assert 'data-action="new-view"' in body, (
+ 'renderSidebarViewDropdown must include a "+ New View" button with data-action="new-view"'
+ )
+
+
+def test_show_sidebar_new_view_input_function_exists() -> None:
+ """showSidebarNewViewInput function must exist in app.js."""
+ assert "function showSidebarNewViewInput" in _JS, (
+ "showSidebarNewViewInput must be defined in app.js"
+ )
+
+
+def test_bind_static_event_listeners_calls_show_sidebar_new_view_input() -> None:
+ """bindStaticEventListeners sidebar dropdown handler must call showSidebarNewViewInput."""
+ match = re.search(
+ r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "bindStaticEventListeners function not found"
+ body = match.group(1)
+ assert "showSidebarNewViewInput" in body, (
+ "bindStaticEventListeners must call showSidebarNewViewInput for sidebar new-view action"
+ )
+
+
+# — Issue 2: Remove shortcut numbers, add session counts ———————————————
+
+def test_render_view_dropdown_no_shortcut_spans() -> None:
+ """renderViewDropdown must not include view-dropdown__shortcut spans (numbers removed)."""
+ match = re.search(
+ r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "renderViewDropdown function not found"
+ body = match.group(1)
+ assert "view-dropdown__shortcut" not in body, (
+ "renderViewDropdown must not include view-dropdown__shortcut spans — shortcut numbers removed"
+ )
+
+
+def test_render_sidebar_view_dropdown_no_shortcut_spans() -> None:
+ """renderSidebarViewDropdown must not include view-dropdown__shortcut spans."""
+ match = re.search(
+ r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "renderSidebarViewDropdown function not found"
+ body = match.group(1)
+ assert "view-dropdown__shortcut" not in body, (
+ "renderSidebarViewDropdown must not include view-dropdown__shortcut spans — shortcut numbers removed"
+ )
+
+
+def test_render_view_dropdown_shows_user_view_session_count() -> None:
+ """renderViewDropdown must show session count for user views."""
+ match = re.search(
+ r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "renderViewDropdown function not found"
+ body = match.group(1)
+ assert "sessions.length" in body or "sessions || []).length" in body, (
+ "renderViewDropdown must show session count for user views (view.sessions.length)"
+ )
+
+
+# — Issue 3: Empty new view opens Add Sessions panel ———————————————————
+
+def test_show_new_view_input_calls_open_add_sessions_panel() -> None:
+ """showNewViewInput must call openAddSessionsPanel after creating a new view."""
+ match = re.search(
+ r"function showNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "showNewViewInput function not found"
+ body = match.group(1)
+ assert "openAddSessionsPanel" in body, (
+ "showNewViewInput must call openAddSessionsPanel() after creating a new view — "
+ "so the user immediately sees the Add Sessions panel for their empty view"
+ )
+
+
+def test_show_sidebar_new_view_input_calls_open_add_sessions_panel() -> None:
+ """showSidebarNewViewInput must call openAddSessionsPanel after creating a new view."""
+ match = re.search(
+ r"function showSidebarNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "showSidebarNewViewInput function not found"
+ body = match.group(1)
+ assert "openAddSessionsPanel" in body, (
+ "showSidebarNewViewInput must call openAddSessionsPanel() after creating a new view"
+ )
+
+
+# — Issue 4: Flyout submenu "+ New View" ———————————————————————————————
+
+def test_open_flyout_submenu_has_new_view_option() -> None:
+ """_openFlyoutSubmenu must include a '+ New View' option."""
+ match = re.search(
+ r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)\n\}",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "_openFlyoutSubmenu function not found"
+ body = match.group(1)
+ assert "new-view-in-flyout" in body, (
+ '_openFlyoutSubmenu must include a "+ New View" option with data-action="new-view-in-flyout"'
+ )
+
+
+def test_open_flyout_submenu_new_view_creates_and_switches() -> None:
+ """_openFlyoutSubmenu '+ New View' handler must create a view and switch to it."""
+ match = re.search(
+ r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)\n\}",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "_openFlyoutSubmenu function not found"
+ body = match.group(1)
+ assert "new-view-in-flyout" in body, (
+ "'new-view-in-flyout' not found in _openFlyoutSubmenu"
+ )
+ # switchView and PATCH must appear in the function body (the handler for new-view-in-flyout)
+ assert "switchView" in body, (
+ '_openFlyoutSubmenu must call switchView — the "+ New View" handler needs to switch to the new view'
+ )
+ assert "PATCH" in body or "api(" in body, (
+ '_openFlyoutSubmenu must PATCH /api/settings to create the view'
+ )
+
+
+# — Issue 5: Add Sessions header button ————————————————————————————————
+
+def test_update_add_sessions_button_function_exists() -> None:
+ """updateAddSessionsButton function must exist in app.js."""
+ assert "function updateAddSessionsButton" in _JS, (
+ "updateAddSessionsButton must be defined in app.js — "
+ "shows/hides the '+ Add' header button based on active view"
+ )
+
+
+def test_switch_view_calls_update_add_sessions_button() -> None:
+ """switchView must call updateAddSessionsButton to update header button visibility."""
+ match = re.search(
+ r"function switchView\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "switchView function not found"
+ body = match.group(1)
+ assert "updateAddSessionsButton" in body, (
+ "switchView must call updateAddSessionsButton() to show/hide the header Add button"
+ )
+
+
+def test_render_grid_no_muxplex_app_onclick() -> None:
+ """renderGrid must not use the broken window.MuxplexApp.openAddSessionsPanel onclick."""
+ fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
+ assert "MuxplexApp.openAddSessionsPanel" not in fn_body, (
+ "renderGrid must not use onclick='window.MuxplexApp.openAddSessionsPanel()' — "
+ "this was broken; the Add Sessions entry point moved to the header button"
+ )
+
+
+def test_bind_static_event_listeners_binds_add_sessions_btn() -> None:
+ """bindStaticEventListeners must bind #add-sessions-btn click to openAddSessionsPanel."""
+ match = re.search(
+ r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
+ _JS,
+ re.DOTALL,
+ )
+ assert match, "bindStaticEventListeners function not found"
+ body = match.group(1)
+ assert "add-sessions-btn" in body, (
+ "bindStaticEventListeners must bind #add-sessions-btn click to openAddSessionsPanel"
+ )
+
+
+# — Issue 6: Tile header flexbox layout ————————————————————————————————
+
+def test_build_tile_html_options_btn_inside_tile_header() -> None:
+ """buildTileHTML must render tile-options-btn inside tile-header (before tile-body)."""
+ fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0]
+ tile_opts_pos = fn_body.find("tile-options-btn")
+ tile_body_pos = fn_body.find("tile-body")
+ assert tile_opts_pos >= 0, "tile-options-btn must appear in buildTileHTML"
+ assert tile_body_pos >= 0, "tile-body must appear in buildTileHTML"
+ assert tile_opts_pos < tile_body_pos, (
+ "tile-options-btn must appear before tile-body in the HTML string — "
+ "it must be inside tile-header (as an inline flex item), not positioned after tile-body"
+ )
+
+
+def test_tile_options_btn_css_not_absolute() -> None:
+ """CSS .tile-options-btn must not use position:absolute — prevent badge/button overlap."""
+ import re as _re
+
+ match = _re.search(r"\.tile-options-btn\s*\{([^}]*)\}", _CSS, _re.DOTALL)
+ assert match, ".tile-options-btn CSS rule not found"
+ rule_body = match.group(1)
+ assert "position: absolute" not in rule_body and "position:absolute" not in rule_body, (
+ ".tile-options-btn must not use position:absolute — "
+ "it should be an inline flex item inside tile-header to prevent device badge overlap"
+ )