fix: UX refinements from live testing — sidebar new view, session counts, empty view flow, header add button, tile layout

Issue 1: Add '+ New View' to sidebar dropdown
- Add '+ New View' action button (data-action='new-view') to renderSidebarViewDropdown()
- Add showSidebarNewViewInput() function targeting #sidebar-view-dropdown-menu
- Wire sidebar dropdown click handler to call showSidebarNewViewInput()
- Also wire 'Manage Views' action in sidebar dropdown

Issue 2: Remove keyboard shortcut numbers, add session counts
- Remove <span class='view-dropdown__shortcut'> elements from both render functions
- Show count of non-hidden sessions next to 'All Sessions'
- Show each user view's session count (view.sessions.length) in parentheses
- Keep keyboard shortcuts functional (backtick, 1-9) — just remove visual display

Issue 3: Empty new view opens Add Sessions panel
- Call openAddSessionsPanel() after switchView() in showNewViewInput() Enter handler
- Same in showSidebarNewViewInput() — user immediately sees panel to populate new view

Issue 4: Add '+ New View' option to tile flyout submenu
- Add separator + '+ New View' button at bottom of _openFlyoutSubmenu() view list
- Click handler: close flyout, prompt for name, validate, create view with session, switchView
- Remove early exit when views.length === 0 (show New View option even with no views)

Issue 5: Fix openAddSessionsPanel entry point + move to header
- Remove broken add-sessions-tile with onclick='window.MuxplexApp.openAddSessionsPanel()'
- Add <button id='add-sessions-btn'> to header-actions in index.html (hidden by default)
- Add updateAddSessionsButton() to show/hide button based on active view
- Wire button click to openAddSessionsPanel() in bindStaticEventListeners()
- Call updateAddSessionsButton() from switchView() and DOMContentLoaded

Issue 6: Fix device badge overlapping flyout icon
- Move tile-options-btn inside tile-header as last flex item (was absolute sibling of article)
- Move device badge out of tile-meta, now a direct flex sibling between tile-name and tile-meta
- Remove tile-meta-sep separator (badge no longer in meta)
- Add gap: 4px to .tile-header CSS
- Remove position:absolute from .tile-options-btn CSS — flex-shrink:0 instead

Tests: Add 17 new static analysis tests in test_frontend_js.py for all 6 issues.
Update test_app.mjs tile tests to match new tile-header layout.
All 318 frontend tests + 224 backend tests pass.
This commit is contained in:
Brian Krabach
2026-04-16 03:53:15 -07:00
parent f9b96ec077
commit 95371712f2
5 changed files with 477 additions and 50 deletions
+209 -31
View File
@@ -539,10 +539,11 @@ function buildTileHTML(session, index, mobile) {
`<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}"${remoteIdAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` + `<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}"${remoteIdAttr} tabindex="0" role="listitem" aria-label="${escapedName}">` +
`<div class="tile-header">` + `<div class="tile-header">` +
`<span class="tile-name">${escapeHtml(name)}</span>` + `<span class="tile-name">${escapeHtml(name)}</span>` +
`<span class="tile-meta">${badgeHtml}${badgeHtml ? `<span class="tile-meta-sep">\xb7</span>` : ''}<span class="tile-time">${escapeHtml(timeStr)}</span></span>` + `${badgeHtml}` +
`<span class="tile-meta">${escapeHtml(timeStr)}</span>` +
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">&#8942;</button>` +
`</div>` + `</div>` +
`<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` + `<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">&#8942;</button>` +
`</article>` `</article>`
); );
} }
@@ -886,24 +887,29 @@ function renderViewDropdown() {
var html = ''; 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' : ''; var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all"><span class="view-dropdown__shortcut">1</span> All Sessions</button>'; html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + allCount + '</span></button>';
// — User views (shortcuts 28) // — User views
if (views.length > 0) { if (views.length > 0) {
html += '<div class="view-dropdown__separator"></div>'; html += '<div class="view-dropdown__separator"></div>';
for (var i = 0; i < views.length && i < 7; i++) { for (var i = 0; i < views.length && i < 7; i++) {
var v = views[i]; var v = views[i];
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : ''; var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown__shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>'; html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + (v.sessions || []).length + '</span></button>';
} }
} }
// — Hidden (N) (always last system view, shortcut 9) // — Hidden (N) (always last system view)
html += '<div class="view-dropdown__separator"></div>'; html += '<div class="view-dropdown__separator"></div>';
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : ''; var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden"><span class="view-dropdown__shortcut">9</span> Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>'; html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden">Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
// — Actions // — Actions
html += '<div class="view-dropdown__separator"></div>'; html += '<div class="view-dropdown__separator"></div>';
@@ -973,9 +979,14 @@ function renderSidebarViewDropdown() {
var html = ''; 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' : ''; var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all"><span class="view-dropdown__shortcut">1</span> All Sessions</button>'; html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions <span class="view-dropdown__count">' + sbAllCount + '</span></button>';
// — User views // — User views
if (views.length > 0) { if (views.length > 0) {
@@ -983,14 +994,19 @@ function renderSidebarViewDropdown() {
for (var i = 0; i < views.length && i < 7; i++) { for (var i = 0; i < views.length && i < 7; i++) {
var v = views[i]; var v = views[i];
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : ''; var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '"><span class="view-dropdown__shortcut">' + (i + 2) + '</span> ' + escapeHtml(v.name) + '</button>'; html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(v.name) + '">' + escapeHtml(v.name) + ' <span class="view-dropdown__count">' + (v.sessions || []).length + '</span></button>';
} }
} }
// — Hidden (N) (always last system view) // — Hidden (N) (always last system view)
html += '<div class="view-dropdown__separator"></div>'; html += '<div class="view-dropdown__separator"></div>';
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : ''; var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden"><span class="view-dropdown__shortcut">9</span> Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>'; html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden">Hidden <span class="view-dropdown__count">' + hiddenCount + '</span></button>';
// — Actions: new view + manage views
html += '<div class="view-dropdown__separator"></div>';
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="new-view">+ New View</button>';
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-views">Manage Views\u2026</button>';
menu.innerHTML = html; menu.innerHTML = html;
} }
@@ -1084,6 +1100,7 @@ function showNewViewInput() {
.then(function() { .then(function() {
if (_serverSettings) _serverSettings.views = updatedViews; if (_serverSettings) _serverSettings.views = updatedViews;
switchView(name); switchView(name);
openAddSessionsPanel();
}) })
.catch(function() { .catch(function() {
showToast('Failed to create view'); 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, * Save updated views array via PATCH /api/settings, update _serverSettings,
* re-render the views settings tab, and re-render the view dropdown. * re-render the views settings tab, and re-render the view dropdown.
@@ -1372,6 +1474,8 @@ function switchView(viewName) {
sidebarLabel.textContent = viewName; sidebarLabel.textContent = viewName;
} }
} }
// Show/hide the header Add Sessions button
updateAddSessionsButton();
// Persist active view — fire and forget // Persist active view — fire and forget
api('PATCH', '/api/state', { active_view: viewName }).catch(function() {}); 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 === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty'); 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 += '<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>';
}
}
if (grid) grid.innerHTML = html + statusTilesHtml; if (grid) grid.innerHTML = html + statusTilesHtml;
// Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped) // 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) || []; var views = (_serverSettings && _serverSettings.views) || [];
if (views.length === 0) {
showToast('No user views. Create one from the header dropdown.');
return;
}
var sessionKey = _flyoutSessionKey; var sessionKey = _flyoutSessionKey;
// When in a user view, filter it out — the user already has "Remove from [ViewName]" for it // 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 += escapeHtml(v.name);
html += '</button>'; html += '</button>';
} }
// — Always show "+ New View" option at the bottom
if (views.length > 0) {
html += '<div class="flyout-menu__separator" role="separator"></div>';
}
html += '<button class="flyout-submenu__item" role="menuitem" data-action="new-view-in-flyout">+ New View</button>';
var submenu = document.createElement('div'); var submenu = document.createElement('div');
submenu.className = 'flyout-submenu'; submenu.className = 'flyout-submenu';
@@ -1971,6 +2061,50 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
// Click handler — toggle view membership via PATCH /api/settings // Click handler — toggle view membership via PATCH /api/settings
submenu.addEventListener('click', function(e) { 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]'); var btn = e.target.closest('[data-view-index]');
if (!btn) return; if (!btn) return;
var idx = parseInt(btn.dataset.viewIndex, 10); var idx = parseInt(btn.dataset.viewIndex, 10);
@@ -2252,6 +2386,28 @@ function closeAddSessionsPanel() {
if (panel) panel.classList.add('hidden'); 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. * Render the session list inside the Add Sessions panel.
* Shows all sessions NOT currently in the active view. * Shows all sessions NOT currently in the active view.
@@ -3763,10 +3919,28 @@ function bindStaticEventListeners() {
// Close sidebar dropdown after selection // Close sidebar dropdown after selection
sidebarViewMenu.classList.add('hidden'); sidebarViewMenu.classList.add('hidden');
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false'); 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 // Click-outside closes the header view dropdown
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var dropdown = $('view-dropdown-menu'); var dropdown = $('view-dropdown-menu');
@@ -4104,6 +4278,7 @@ document.addEventListener('DOMContentLoaded', async function() {
updatePageTitle(); updatePageTitle();
startHeartbeat(); startHeartbeat();
bindStaticEventListeners(); bindStaticEventListeners();
updateAddSessionsButton();
}) })
.catch(function(err) { .catch(function(err) {
console.error('[init] restoreState failed, retrying in 5s:', err); console.error('[init] restoreState failed, retrying in 5s:', err);
@@ -4200,6 +4375,9 @@ if (typeof module !== 'undefined' && module.exports) {
// Sidebar view dropdown // Sidebar view dropdown
renderSidebarViewDropdown, renderSidebarViewDropdown,
toggleSidebarViewDropdown, toggleSidebarViewDropdown,
showSidebarNewViewInput,
// Add Sessions header button
updateAddSessionsButton,
// Manage Views settings tab // Manage Views settings tab
renderViewsSettingsTab, renderViewsSettingsTab,
_saveViewsAndRerender, _saveViewsAndRerender,
+1
View File
@@ -27,6 +27,7 @@
<div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div> <div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button id="add-sessions-btn" class="header-btn hidden" aria-label="Add sessions to view">+ Add</button>
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button> <button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">&#9638;</button> <button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">&#9638;</button>
<button id="settings-btn" class="header-btn" aria-label="Settings">&#9881;</button> <button id="settings-btn" class="header-btn" aria-label="Settings">&#9881;</button>
+2 -4
View File
@@ -208,6 +208,7 @@ body {
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px;
} }
.tile-name { .tile-name {
@@ -2216,9 +2217,7 @@ body {
/* —— Tile Flyout Menu ————————————————————————————————————————————————————— */ /* —— Tile Flyout Menu ————————————————————————————————————————————————————— */
.tile-options-btn { .tile-options-btn {
position: absolute; flex-shrink: 0;
top: 6px;
right: 6px;
width: 24px; width: 24px;
height: 24px; height: 24px;
display: flex; display: flex;
@@ -2231,7 +2230,6 @@ body {
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
z-index: 2;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast); transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
} }
+18 -12
View File
@@ -2614,30 +2614,36 @@ test('buildTileHTML escapes HTML in deviceName within device-badge', () => {
// --- buildTileHTML device badge placement (task-3) --- // --- 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 }); app._setServerSettings({ multi_device_enabled: true });
const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' }; const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' };
const html = app.buildTileHTML(session, 0, false); const html = app.buildTileHTML(session, 0, false);
const tileMetaStart = html.indexOf('<span class="tile-meta">'); // Badge is now a sibling flex item inside tile-header, after tile-name, before tile-meta/button
// Note: finds the first </span> after tileMetaStart, which is device-badge's closing tag const tileHeaderStart = html.indexOf('<div class="tile-header">');
// (not tile-meta's own close), but the assertion still holds because device-badge const tileHeaderEnd = html.indexOf('</div>', tileHeaderStart);
// opens and closes before tile-time within the tile-meta container. assert.ok(tileHeaderStart !== -1, 'tile-header div should exist');
const tileMetaEnd = html.indexOf('</span>', tileMetaStart);
assert.ok(tileMetaStart !== -1, 'tile-meta span should exist');
const deviceBadgePos = html.indexOf('device-badge'); const deviceBadgePos = html.indexOf('device-badge');
assert.ok(deviceBadgePos !== -1, 'device-badge should exist in tile HTML');
assert.ok( assert.ok(
deviceBadgePos > tileMetaStart && deviceBadgePos < tileMetaEnd, deviceBadgePos > tileHeaderStart && deviceBadgePos < tileHeaderEnd,
`device-badge should be inside tile-meta span (tile-meta starts at ${tileMetaStart}, device-badge at ${deviceBadgePos}, tile-meta closes at ${tileMetaEnd})` `device-badge should be inside tile-header (tile-header starts at ${tileHeaderStart}, device-badge at ${deviceBadgePos}, tile-header closes at ${tileHeaderEnd})`
); );
app._setServerSettings(null); 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 }); app._setServerSettings({ multi_device_enabled: true });
const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' }; const session = { name: 'work', deviceName: 'Laptop', sessionKey: '::work', snapshot: '' };
const html = app.buildTileHTML(session, 0, false); const html = app.buildTileHTML(session, 0, false);
assert.ok(html.includes('tile-meta-sep'), 'should include tile-meta-sep element when badge is present'); // Badge is directly in tile-header (not inside tile-meta)
assert.ok(html.includes('\u00b7'), 'should include middle dot separator (\u00b7)'); 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('<div class="tile-header">');
const bodyStart = html.indexOf('<div class="tile-body">');
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); app._setServerSettings(null);
}); });
+247 -3
View File
@@ -3948,10 +3948,22 @@ def test_open_flyout_menu_checks_mobile() -> None:
def test_render_grid_has_add_sessions_affordance() -> 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] fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
assert "add-sessions" in fn_body.lower() or "openAddSessionsPanel" in fn_body, ( # Old approach: tile in the grid | New approach: header button via updateAddSessionsButton
"renderGrid must render an 'Add Sessions' affordance for user views" 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, ( assert 'data-action="cancel" role="button"' in _JS, (
"Cancel button inside alertdialog must use role='button'" "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"
)