feat: UX overhaul — Manage View panel, dropdown refinements, label fixes, badge sizing

This commit is contained in:
Brian Krabach
2026-04-16 06:56:59 -07:00
parent 95371712f2
commit 29b180a713
6 changed files with 652 additions and 339 deletions
+200 -199
View File
@@ -911,10 +911,15 @@ function renderViewDropdown() {
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">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 (stronger separator)
html += '<div class="view-dropdown__separator"></div>'; html += '<div class="view-dropdown__separator view-dropdown__separator--strong"></div>';
// Only show "Manage [ViewName]\u2026" when a user view is active
if (_activeView !== 'all' && _activeView !== 'hidden') {
var displayViewName = _activeView.length > 20 ? _activeView.substring(0, 20) + '\u2026' : _activeView;
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-view">Manage \u201c' + escapeHtml(displayViewName) + '\u201d\u2026</button>';
}
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-views">Manage All Views\u2026</button>';
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="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;
@@ -1003,10 +1008,15 @@ function renderSidebarViewDropdown() {
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">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 // — Actions (stronger separator)
html += '<div class="view-dropdown__separator"></div>'; html += '<div class="view-dropdown__separator view-dropdown__separator--strong"></div>';
// Only show "Manage [ViewName]…" when a user view is active
if (_activeView !== 'all' && _activeView !== 'hidden') {
var sbDisplayViewName = _activeView.length > 20 ? _activeView.substring(0, 20) + '…' : _activeView;
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-view">Manage “' + escapeHtml(sbDisplayViewName) + '”…</button>';
}
html += '<button class="view-dropdown__item view-dropdown__action" role="menuitem" data-action="manage-views">Manage All Views…</button>';
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="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;
} }
@@ -1100,7 +1110,7 @@ function showNewViewInput() {
.then(function() { .then(function() {
if (_serverSettings) _serverSettings.views = updatedViews; if (_serverSettings) _serverSettings.views = updatedViews;
switchView(name); switchView(name);
openAddSessionsPanel(); openManageViewPanel();
}) })
.catch(function() { .catch(function() {
showToast('Failed to create view'); showToast('Failed to create view');
@@ -1122,7 +1132,7 @@ function showNewViewInput() {
/** /**
* Show an inline text input inside the SIDEBAR view dropdown for creating a new view. * 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. * Targets #sidebar-view-dropdown-menu instead of #view-dropdown-menu.
* - On Enter: validates, PATCHes /api/settings, calls switchView + openAddSessionsPanel. * - On Enter: validates, PATCHes /api/settings, calls switchView + openManageViewPanel.
* - On Escape / blur: closes the sidebar dropdown. * - On Escape / blur: closes the sidebar dropdown.
*/ */
function showSidebarNewViewInput() { function showSidebarNewViewInput() {
@@ -1185,7 +1195,7 @@ function showSidebarNewViewInput() {
if (_serverSettings) _serverSettings.views = updatedViews; if (_serverSettings) _serverSettings.views = updatedViews;
closeSidebarDropdown(); closeSidebarDropdown();
switchView(name); switchView(name);
openAddSessionsPanel(); openManageViewPanel();
}) })
.catch(function() { .catch(function() {
showToast('Failed to create view'); showToast('Failed to create view');
@@ -1241,7 +1251,7 @@ function renderViewsSettingsTab() {
if (emptyEl) emptyEl.style.display = 'none'; if (emptyEl) emptyEl.style.display = 'none';
// Build the list of view rows // Build the list of view rows (no inline rename — rename is in Manage View panel)
listEl.innerHTML = ''; listEl.innerHTML = '';
views.forEach(function(view, idx) { views.forEach(function(view, idx) {
var viewSessions = view.sessions || []; var viewSessions = view.sessions || [];
@@ -1251,11 +1261,10 @@ function renderViewsSettingsTab() {
row.className = 'views-settings-row'; row.className = 'views-settings-row';
row.setAttribute('data-view-idx', String(idx)); row.setAttribute('data-view-idx', String(idx));
// Name span (clickable for rename) // Name span (not clickable for rename — rename is in Manage View panel)
var nameSpan = document.createElement('span'); var nameSpan = document.createElement('span');
nameSpan.className = 'views-settings-name'; nameSpan.className = 'views-settings-name';
nameSpan.textContent = view.name; nameSpan.textContent = view.name;
nameSpan.title = 'Click to rename';
// Session count // Session count
var countSpan = document.createElement('span'); var countSpan = document.createElement('span');
@@ -1269,7 +1278,7 @@ function renderViewsSettingsTab() {
// Up button // Up button
var upBtn = document.createElement('button'); var upBtn = document.createElement('button');
upBtn.className = 'views-settings-btn'; upBtn.className = 'views-settings-btn';
upBtn.textContent = ''; upBtn.textContent = '\u25b2';
upBtn.title = 'Move up'; upBtn.title = 'Move up';
upBtn.setAttribute('data-action', 'move-up'); upBtn.setAttribute('data-action', 'move-up');
upBtn.setAttribute('data-idx', String(idx)); upBtn.setAttribute('data-idx', String(idx));
@@ -1278,12 +1287,19 @@ function renderViewsSettingsTab() {
// Down button // Down button
var downBtn = document.createElement('button'); var downBtn = document.createElement('button');
downBtn.className = 'views-settings-btn'; downBtn.className = 'views-settings-btn';
downBtn.textContent = ''; downBtn.textContent = '\u25bc';
downBtn.title = 'Move down'; downBtn.title = 'Move down';
downBtn.setAttribute('data-action', 'move-down'); downBtn.setAttribute('data-action', 'move-down');
downBtn.setAttribute('data-idx', String(idx)); downBtn.setAttribute('data-idx', String(idx));
if (idx === views.length - 1) downBtn.disabled = true; if (idx === views.length - 1) downBtn.disabled = true;
// Manage button (opens Manage View panel — close settings first)
var manageBtn = document.createElement('button');
manageBtn.className = 'views-settings-btn views-settings-btn--manage';
manageBtn.textContent = 'Manage';
manageBtn.setAttribute('data-action', 'manage');
manageBtn.setAttribute('data-idx', String(idx));
// Delete button // Delete button
var deleteBtn = document.createElement('button'); var deleteBtn = document.createElement('button');
deleteBtn.className = 'views-settings-btn views-settings-btn--danger'; deleteBtn.className = 'views-settings-btn views-settings-btn--danger';
@@ -1293,6 +1309,7 @@ function renderViewsSettingsTab() {
actionsDiv.appendChild(upBtn); actionsDiv.appendChild(upBtn);
actionsDiv.appendChild(downBtn); actionsDiv.appendChild(downBtn);
actionsDiv.appendChild(manageBtn);
actionsDiv.appendChild(deleteBtn); actionsDiv.appendChild(deleteBtn);
row.appendChild(nameSpan); row.appendChild(nameSpan);
@@ -1301,6 +1318,16 @@ function renderViewsSettingsTab() {
listEl.appendChild(row); listEl.appendChild(row);
}); });
// Add "+ New View" button at the bottom
var newViewRow = document.createElement('div');
newViewRow.className = 'views-settings-new-row';
var newViewBtn = document.createElement('button');
newViewBtn.className = 'views-settings-btn views-settings-btn--new';
newViewBtn.textContent = '+ New View';
newViewBtn.setAttribute('data-action', 'new-view-in-settings');
newViewRow.appendChild(newViewBtn);
listEl.appendChild(newViewRow);
// Delegated click handler on the list // Delegated click handler on the list
listEl.onclick = function(e) { listEl.onclick = function(e) {
var views = (_serverSettings && _serverSettings.views) || []; var views = (_serverSettings && _serverSettings.views) || [];
@@ -1332,6 +1359,17 @@ function renderViewsSettingsTab() {
return; return;
} }
// Manage: close settings, switch to that view, open Manage View panel
if (target.getAttribute('data-action') === 'manage') {
var idx = parseInt(target.getAttribute('data-idx'), 10);
var viewName = views[idx] && views[idx].name;
if (!viewName) return;
closeSettings();
switchView(viewName);
openManageViewPanel();
return;
}
// Delete: show inline confirm // Delete: show inline confirm
if (target.getAttribute('data-action') === 'delete') { if (target.getAttribute('data-action') === 'delete') {
var idx = parseInt(target.getAttribute('data-idx'), 10); var idx = parseInt(target.getAttribute('data-idx'), 10);
@@ -1385,69 +1423,34 @@ function renderViewsSettingsTab() {
return; return;
} }
// Click on name: inline rename // + New View: create a new view and open Manage View panel
if (target.classList.contains('views-settings-name')) { if (target.getAttribute('data-action') === 'new-view-in-settings') {
var row = target.closest('.views-settings-row'); var newName = prompt('View name:');
if (!row) return; if (!newName || !newName.trim()) return;
var idx = parseInt(row.getAttribute('data-view-idx'), 10); newName = newName.trim();
var currentName = views[idx] && views[idx].name; if (newName.toLowerCase() === 'all' || newName.toLowerCase() === 'hidden') {
if (currentName === undefined) return; showToast('Cannot use reserved name \'' + newName + '\'');
return;
var input = document.createElement('input');
input.type = 'text';
input.className = 'views-settings-rename-input';
input.value = currentName;
input.maxLength = 30;
target.parentNode.replaceChild(input, target);
input.focus();
input.select();
var committed = false;
function commitRename() {
if (committed) return;
var newName = input.value.trim();
if (!newName || newName === currentName) {
renderViewsSettingsTab();
return;
}
// Validate: not reserved (case-insensitive)
if (newName.toLowerCase() === 'all' || newName.toLowerCase() === 'hidden') {
showToast('Cannot use reserved name \'' + newName + '\'');
renderViewsSettingsTab();
return;
}
// Validate: not duplicate
var duplicate = views.find(function(v, i) { return i !== idx && v.name === newName; });
if (duplicate) {
showToast('View \'' + newName + '\' already exists');
renderViewsSettingsTab();
return;
}
// Update the view name
committed = true;
var updated = views.map(function(v, i) {
return i === idx ? Object.assign({}, v, { name: newName }) : v;
});
// If this was the active view, update _activeView
if (_activeView === currentName) {
_activeView = newName;
api('PATCH', '/api/state', { active_view: _activeView }).catch(function() {});
}
_saveViewsAndRerender(updated);
} }
if (views.find(function(v) { return v.name === newName; })) {
input.addEventListener('keydown', function(e) { showToast('View \'' + newName + '\' already exists');
if (e.key === 'Enter') { return;
e.preventDefault(); }
commitRename(); var updatedViews = views.concat([{ name: newName, sessions: [] }]);
} else if (e.key === 'Escape') { api('PATCH', '/api/settings', { views: updatedViews })
.then(function() {
if (_serverSettings) _serverSettings.views = updatedViews;
renderViewsSettingsTab(); renderViewsSettingsTab();
} renderViewDropdown();
}); // Close settings and open Manage View panel for the new view
closeSettings();
input.addEventListener('blur', function() { switchView(newName);
commitRename(); openManageViewPanel();
}); })
.catch(function() {
showToast('Failed to create view');
});
return;
} }
}; };
} }
@@ -1474,8 +1477,6 @@ 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() {});
} }
@@ -2346,78 +2347,56 @@ function _executeKill(name, remoteId) {
}); });
} }
// ─── Add Sessions Panel ────────────────────────────────────────────────────────────────────────── // ─── Manage View Panel ──────────────────────────────────────────────────────────────────────────
/** /**
* Open the Add Sessions panel for the active user view. * Open the Manage View panel for the active user view.
* Only available for user views (not "All" or "Hidden"). * Only available for user views (not "All" or "Hidden").
*/ */
function openAddSessionsPanel() { function openManageViewPanel() {
if (_activeView === 'all' || _activeView === 'hidden') return; if (_activeView === 'all' || _activeView === 'hidden') return;
var panel = $('add-sessions-panel'); var panel = $('manage-view-panel');
if (!panel) return; if (!panel) return;
// Update title // Update title/name
var titleEl = $('add-sessions-title'); var nameEl = $('manage-view-name');
if (titleEl) titleEl.textContent = 'Add Sessions to “' + _activeView + '”'; if (nameEl) nameEl.textContent = _activeView;
renderAddSessionsList(); renderManageViewList();
panel.classList.remove('hidden'); panel.classList.remove('hidden');
// Close on backdrop click // Close on backdrop click
var backdrop = $('add-sessions-backdrop'); var backdrop = $('manage-view-backdrop');
if (backdrop) { if (backdrop) {
backdrop.onclick = closeAddSessionsPanel; backdrop.onclick = closeManageViewPanel;
} }
// Close button // Close button at bottom
var closeBtn = $('add-sessions-close'); var closeBtn = $('manage-view-close');
if (closeBtn) { if (closeBtn) {
closeBtn.onclick = closeAddSessionsPanel; closeBtn.onclick = closeManageViewPanel;
} }
} }
/** /**
* Close the Add Sessions panel. * Close the Manage View panel.
*/ */
function closeAddSessionsPanel() { function closeManageViewPanel() {
var panel = $('add-sessions-panel'); var panel = $('manage-view-panel');
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. * Render the session list inside the Manage View panel.
* Visible only when in a user-created view (not 'all' or 'hidden'). * Shows ALL sessions: checked = in this view, unchecked = not in this view.
* Called from switchView() and on initial load. * Checked items sorted first, then unchecked. Within each group, alphabetical by device.
* Immediate-commit: each checkbox toggle fires PATCH /api/settings immediately.
* Hidden sessions: dimmed with "hidden" badge. Static note below for hidden items.
*/ */
function updateAddSessionsButton() { function renderManageViewList() {
var btn = $('add-sessions-btn'); var listEl = $('manage-view-list');
if (!btn) return; var summaryEl = $('manage-view-summary');
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.
* 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; if (!listEl) return;
var views = (_serverSettings && _serverSettings.views) || []; var views = (_serverSettings && _serverSettings.views) || [];
@@ -2434,103 +2413,113 @@ function renderAddSessionsList() {
if (!activeViewObj) { listEl.innerHTML = ''; return; } if (!activeViewObj) { listEl.innerHTML = ''; return; }
var viewSessions = activeViewObj.sessions || []; var viewSessions = activeViewObj.sessions || [];
// Get all real sessions (not status entries), excluding those already in the view // Get all real sessions (not status entries)
var allSessions = (_currentSessions || []).filter(function(s) { var allSessions = (_currentSessions || []).filter(function(s) {
return !s.status; return !s.status;
}); });
// Update summary line
if (summaryEl) {
summaryEl.textContent = allSessions.length + ' sessions · ' + viewSessions.length + ' in this view';
}
// Partition into inView (checked first) and notInView
var inView = allSessions.filter(function(s) {
var key = s.sessionKey || s.name;
return viewSessions.indexOf(key) !== -1 || viewSessions.indexOf(s.name) !== -1;
});
var notInView = allSessions.filter(function(s) { var notInView = allSessions.filter(function(s) {
var key = s.sessionKey || s.name; var key = s.sessionKey || s.name;
return viewSessions.indexOf(key) === -1; return viewSessions.indexOf(key) === -1 && viewSessions.indexOf(s.name) === -1;
}); });
if (notInView.length === 0) { // Sort each group: alphabetical, grouped by device
listEl.innerHTML = ''; function sortByDeviceAlpha(arr) {
if (emptyEl) emptyEl.style.display = ''; return arr.slice().sort(function(a, b) {
return; var da = (_getDeviceDisplayName(a) || '').toLowerCase();
var db = (_getDeviceDisplayName(b) || '').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;
});
} }
if (emptyEl) emptyEl.style.display = 'none';
// Sort: group by deviceName, alphabetical within each group var sorted = sortByDeviceAlpha(inView).concat(sortByDeviceAlpha(notInView));
notInView.sort(function(a, b) {
var da = (_getDeviceDisplayName(a) || '').toLowerCase();
var db = (_getDeviceDisplayName(b) || '').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 = ''; var html = '';
for (var j = 0; j < notInView.length; j++) { for (var j = 0; j < sorted.length; j++) {
var s = notInView[j]; var s = sorted[j];
var key = s.sessionKey || s.name; var key = s.sessionKey || s.name;
var isInView = viewSessions.indexOf(key) !== -1 || viewSessions.indexOf(s.name) !== -1;
var isHidden = hidden.indexOf(key) !== -1 || hidden.indexOf(s.name) !== -1; var isHidden = hidden.indexOf(key) !== -1 || hidden.indexOf(s.name) !== -1;
var escapedName = escapeHtml(s.name || ''); var escapedName = escapeHtml(s.name || '');
var deviceName = escapeHtml(_getDeviceDisplayName(s) || ''); var deviceName = escapeHtml(_getDeviceDisplayName(s) || '');
html += '<label class="add-sessions-item' + (isHidden ? ' add-sessions-item--hidden' : '') + '">'; html += '<label class="manage-view-item' + (isHidden ? ' manage-view-item--hidden' : '') + '">';
html += '<input type="checkbox" class="add-sessions-item__checkbox" data-session-key="' + escapeHtml(key) + '"' + (isHidden ? ' data-is-hidden="1"' : '') + ' />'; html += '<input type="checkbox" class="manage-view-item__checkbox" data-session-key="' + escapeHtml(key) + '"' + (isInView ? ' checked' : '') + (isHidden ? ' data-is-hidden="1"' : '') + ' />';
html += '<span class="add-sessions-item__name">' + escapedName + '</span>'; html += '<span class="manage-view-item__name">' + escapedName + '</span>';
if (deviceName) html += '<span class="add-sessions-item__device">' + deviceName + '</span>'; if (deviceName) html += '<span class="manage-view-item__device">' + deviceName + '</span>';
if (isHidden) html += '<span class="add-sessions-item__badge">hidden</span>'; if (isHidden) html += '<span class="manage-view-item__badge">hidden</span>';
html += '</label>'; html += '</label>';
if (isHidden) { if (isHidden) {
html += '<div class="add-sessions-item__disclosure">This will make it visible again.</div>'; html += '<div class="manage-view-item__disclosure">Adding will unhide this session</div>';
} }
} }
listEl.innerHTML = html; listEl.innerHTML = html;
// Delegated change handler for immediate-commit checkboxes // Delegated change handler for immediate-commit checkboxes
// Each checkbox change fires api('PATCH', '/api/settings') immediately (no batch Done).
listEl.onchange = function(e) { listEl.onchange = function(e) {
var cb = e.target.closest('.add-sessions-item__checkbox'); var cb = e.target.closest('.manage-view-item__checkbox');
if (!cb) return; if (!cb) return;
var sessionKey = cb.dataset.sessionKey; var sessionKey = cb.dataset.sessionKey;
var isChecked = cb.checked;
var isHiddenSession = cb.dataset.isHidden === '1'; var isHiddenSession = cb.dataset.isHidden === '1';
if (cb.checked) { var views = (_serverSettings && _serverSettings.views) || [];
var views = (_serverSettings && _serverSettings.views) || []; var updatedViews = JSON.parse(JSON.stringify(views));
var updatedViews = JSON.parse(JSON.stringify(views));
for (var vi = 0; vi < updatedViews.length; vi++) {
if (updatedViews[vi].name === _activeView) {
var vs = updatedViews[vi].sessions || [];
if (vs.indexOf(sessionKey) === -1) vs.push(sessionKey);
updatedViews[vi].sessions = vs;
break;
}
}
var patch = { views: updatedViews };
if (isHiddenSession) {
var hiddenList = (_serverSettings && _serverSettings.hidden_sessions) || [];
var hi = hiddenList.indexOf(sessionKey);
if (hi !== -1) {
var updatedHidden = hiddenList.slice();
updatedHidden.splice(hi, 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;
}
renderAddSessionsList();
renderGrid(_currentSessions || []);
})
.catch(function(err) {
showToast('Couldnt save — try again');
if (cb) cb.checked = false;
console.warn('[renderAddSessionsList] PATCH failed:', err);
});
} else {
// Should not normally happen (unchecking means removing), but handle gracefully
cb.checked = false;
}
};
for (var vi = 0; vi < updatedViews.length; vi++) {
if (updatedViews[vi].name === _activeView) {
var vs = updatedViews[vi].sessions || [];
if (isChecked) {
if (vs.indexOf(sessionKey) === -1) vs.push(sessionKey);
} else {
var pos = vs.indexOf(sessionKey);
if (pos !== -1) vs.splice(pos, 1);
}
updatedViews[vi].sessions = vs;
break;
}
}
var patch = { views: updatedViews };
if (isHiddenSession && isChecked) {
var hiddenList = (_serverSettings && _serverSettings.hidden_sessions) || [];
var hi = hiddenList.indexOf(sessionKey);
if (hi !== -1) {
var updatedHidden = hiddenList.slice();
updatedHidden.splice(hi, 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;
}
renderManageViewList();
renderGrid(_currentSessions || []);
})
.catch(function(err) {
showToast('Couldnt save — try again');
if (cb) cb.checked = !isChecked;
console.warn('[renderManageViewList] PATCH failed:', err);
});
};
} }
/** /**
@@ -2548,6 +2537,7 @@ function _getDeviceDisplayName(session) {
return ''; return '';
} }
// ─── Notification permission ──────────────────────────────────────────────── // ─── Notification permission ────────────────────────────────────────────────
/** /**
@@ -3895,6 +3885,9 @@ function bindStaticEventListeners() {
if (action) { if (action) {
if (action.dataset.action === 'new-view') { if (action.dataset.action === 'new-view') {
showNewViewInput(); showNewViewInput();
} else if (action.dataset.action === 'manage-view') {
closeViewDropdown();
openManageViewPanel();
} else if (action.dataset.action === 'manage-views') { } else if (action.dataset.action === 'manage-views') {
closeViewDropdown(); closeViewDropdown();
openSettings(); openSettings();
@@ -3925,6 +3918,10 @@ function bindStaticEventListeners() {
if (action) { if (action) {
if (action.dataset.action === 'new-view') { if (action.dataset.action === 'new-view') {
showSidebarNewViewInput(); showSidebarNewViewInput();
} else if (action.dataset.action === 'manage-view') {
sidebarViewMenu.classList.add('hidden');
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
openManageViewPanel();
} else if (action.dataset.action === 'manage-views') { } else if (action.dataset.action === 'manage-views') {
sidebarViewMenu.classList.add('hidden'); sidebarViewMenu.classList.add('hidden');
if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false'); if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false');
@@ -3938,9 +3935,6 @@ function bindStaticEventListeners() {
}); });
} }
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');
@@ -3958,6 +3952,8 @@ function bindStaticEventListeners() {
if (!sidebarDropdown || sidebarDropdown.classList.contains('hidden')) return; if (!sidebarDropdown || sidebarDropdown.classList.contains('hidden')) return;
var sidebarTrigger = $('sidebar-view-dropdown-trigger'); var sidebarTrigger = $('sidebar-view-dropdown-trigger');
if (sidebarTrigger && sidebarTrigger.contains(e.target)) return; if (sidebarTrigger && sidebarTrigger.contains(e.target)) return;
// Don't close if a new-view input was just created (replaceChild removes the click target from DOM)
if (sidebarDropdown.querySelector('.view-dropdown__new-input')) return;
if (!sidebarDropdown.contains(e.target)) { if (!sidebarDropdown.contains(e.target)) {
sidebarDropdown.classList.add('hidden'); sidebarDropdown.classList.add('hidden');
if (sidebarTrigger) sidebarTrigger.setAttribute('aria-expanded', 'false'); if (sidebarTrigger) sidebarTrigger.setAttribute('aria-expanded', 'false');
@@ -4278,7 +4274,14 @@ document.addEventListener('DOMContentLoaded', async function() {
updatePageTitle(); updatePageTitle();
startHeartbeat(); startHeartbeat();
bindStaticEventListeners(); bindStaticEventListeners();
updateAddSessionsButton(); renderViewDropdown();
// Update sidebar label after restoreState sets _activeView (Issue 7)
var sidebarLabelEl = $('sidebar-view-label');
if (sidebarLabelEl) {
if (_activeView === 'all') sidebarLabelEl.textContent = 'All Sessions';
else if (_activeView === 'hidden') sidebarLabelEl.textContent = 'Hidden';
else sidebarLabelEl.textContent = _activeView;
}
}) })
.catch(function(err) { .catch(function(err) {
console.error('[init] restoreState failed, retrying in 5s:', err); console.error('[init] restoreState failed, retrying in 5s:', err);
@@ -4357,10 +4360,10 @@ if (typeof module !== 'undefined' && module.exports) {
createNewSession, createNewSession,
// Kill session // Kill session
killSession, killSession,
// Add Sessions panel // Manage View panel
openAddSessionsPanel, openManageViewPanel,
closeAddSessionsPanel, closeManageViewPanel,
renderAddSessionsList, renderManageViewList,
// Flyout menu // Flyout menu
openFlyoutMenu, openFlyoutMenu,
closeFlyoutMenu, closeFlyoutMenu,
@@ -4376,8 +4379,6 @@ if (typeof module !== 'undefined' && module.exports) {
renderSidebarViewDropdown, renderSidebarViewDropdown,
toggleSidebarViewDropdown, toggleSidebarViewDropdown,
showSidebarNewViewInput, showSidebarNewViewInput,
// Add Sessions header button
updateAddSessionsButton,
// Manage Views settings tab // Manage Views settings tab
renderViewsSettingsTab, renderViewsSettingsTab,
_saveViewsAndRerender, _saveViewsAndRerender,
+14 -11
View File
@@ -27,7 +27,6 @@
<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>
@@ -87,16 +86,20 @@
</div> </div>
</div> </div>
<!-- —— Add Sessions panel ——————————————————————————————————————————— --> <!-- —— Manage View panel ———————————————————————————————————————————————————————————————————————————————————— -->
<div id="add-sessions-panel" class="add-sessions-panel hidden" role="dialog" aria-modal="true" aria-label="Add sessions to view"> <div id="manage-view-panel" class="manage-view-panel hidden" role="dialog" aria-modal="true" aria-label="Manage view">
<div class="add-sessions-panel__backdrop" id="add-sessions-backdrop"></div> <div class="manage-view-panel__backdrop" id="manage-view-backdrop"></div>
<div class="add-sessions-panel__content"> <div class="manage-view-panel__content">
<div class="add-sessions-panel__header"> <div class="manage-view-panel__header">
<h2 id="add-sessions-title" class="add-sessions-panel__title">Add Sessions</h2> <div class="manage-view-panel__name-row">
<button id="add-sessions-close" class="add-sessions-panel__close" aria-label="Close">&times;</button> <h2 id="manage-view-name" class="manage-view-panel__name">View Name</h2>
</div>
<p id="manage-view-summary" class="manage-view-panel__summary"></p>
</div>
<div id="manage-view-list" class="manage-view-panel__list"></div>
<div class="manage-view-panel__footer">
<button id="manage-view-close" class="manage-view-panel__close-btn">Close</button>
</div> </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>
</div> </div>
@@ -228,7 +231,7 @@
<button id="setting-template-reset" class="settings-action-btn">Reset to default</button> <button id="setting-template-reset" class="settings-action-btn">Reset to default</button>
</div> </div>
<div class="settings-field settings-field--column"> <div class="settings-field settings-field--column">
<label class="settings-label" for="setting-delete-template">Delete session command</label> <label class="settings-label" for="setting-delete-template">Kill session command</label>
<textarea id="setting-delete-template" class="settings-textarea" rows="3" placeholder="tmux kill-session -t {name}"></textarea> <textarea id="setting-delete-template" class="settings-textarea" rows="3" placeholder="tmux kill-session -t {name}"></textarea>
<span class="settings-helper">{name} is replaced with the session name</span> <span class="settings-helper">{name} is replaced with the session name</span>
<button id="setting-delete-template-reset" class="settings-action-btn">Reset to default</button> <button id="setting-delete-template-reset" class="settings-action-btn">Reset to default</button>
+107 -21
View File
@@ -1687,6 +1687,11 @@ body {
background: var(--border-subtle); background: var(--border-subtle);
} }
.view-dropdown__separator--strong {
border-color: var(--border);
margin: 6px 0;
}
.view-dropdown__action { .view-dropdown__action {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1731,14 +1736,16 @@ body {
============================================================ */ ============================================================ */
.device-badge { .device-badge {
display: inline-block; display: inline-flex;
font-size: 9px; align-items: center;
font-size: 12px;
color: var(--accent); color: var(--accent);
background: var(--accent-dim); background: var(--accent-dim);
border: 1px solid var(--accent); border: 1px solid var(--accent);
border-radius: 4px; border-radius: 4px;
padding: 1px 4px; padding: 3px 6px;
line-height: 1.4; line-height: 1.4;
min-height: 22px;
vertical-align: middle; vertical-align: middle;
} }
@@ -1818,7 +1825,7 @@ body {
/* —— Add Sessions Panel —————————————————————————————————————————————————————————————————————————— */ /* —— Add Sessions Panel —————————————————————————————————————————————————————————————————————————— */
.add-sessions-panel { .manage-view-panel {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 250; z-index: 250;
@@ -1827,13 +1834,13 @@ body {
justify-content: center; justify-content: center;
} }
.add-sessions-panel__backdrop { .manage-view-panel__backdrop {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: var(--bg-overlay); background: var(--bg-overlay);
} }
.add-sessions-panel__content { .manage-view-panel__content {
position: relative; position: relative;
width: 90%; width: 90%;
max-width: 440px; max-width: 440px;
@@ -1846,7 +1853,7 @@ body {
overflow: hidden; overflow: hidden;
} }
.add-sessions-panel__header { .manage-view-panel__header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -1854,14 +1861,14 @@ body {
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
} }
.add-sessions-panel__title { .manage-view-panel__title {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
margin: 0; margin: 0;
} }
.add-sessions-panel__close { .manage-view-panel__close {
width: 28px; width: 28px;
height: 28px; height: 28px;
display: flex; display: flex;
@@ -1875,25 +1882,25 @@ body {
border-radius: 4px; border-radius: 4px;
} }
.add-sessions-panel__close:hover { .manage-view-panel__close:hover {
background: var(--bg-surface); background: var(--bg-surface);
color: var(--text); color: var(--text);
} }
.add-sessions-panel__list { .manage-view-panel__list {
overflow-y: auto; overflow-y: auto;
padding: 8px 0; padding: 8px 0;
flex: 1; flex: 1;
} }
.add-sessions-panel__empty { .manage-view-panel__empty {
padding: 24px 16px; padding: 24px 16px;
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
} }
.add-sessions-item { .manage-view-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
@@ -1901,20 +1908,20 @@ body {
cursor: pointer; cursor: pointer;
} }
.add-sessions-item:hover { .manage-view-item:hover {
background: var(--bg-surface); background: var(--bg-surface);
} }
.add-sessions-item--hidden { .manage-view-item--hidden {
opacity: 0.5; opacity: 0.5;
} }
.add-sessions-item__checkbox { .manage-view-item__checkbox {
flex-shrink: 0; flex-shrink: 0;
accent-color: var(--accent); accent-color: var(--accent);
} }
.add-sessions-item__name { .manage-view-item__name {
font-size: 13px; font-size: 13px;
color: var(--text); color: var(--text);
flex: 1; flex: 1;
@@ -1924,13 +1931,13 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.add-sessions-item__device { .manage-view-item__device {
font-size: 11px; font-size: 11px;
color: var(--text-dim); color: var(--text-dim);
white-space: nowrap; white-space: nowrap;
} }
.add-sessions-item__badge { .manage-view-item__badge {
font-size: 10px; font-size: 10px;
color: var(--text-dim); color: var(--text-dim);
background: var(--bg); background: var(--bg);
@@ -1939,7 +1946,7 @@ body {
padding: 1px 5px; padding: 1px 5px;
} }
.add-sessions-item__disclosure { .manage-view-item__disclosure {
width: 100%; width: 100%;
padding: 2px 16px 6px 42px; padding: 2px 16px 6px 42px;
font-size: 11px; font-size: 11px;
@@ -1949,7 +1956,7 @@ body {
/* Mobile: full-screen sheet for Add Sessions */ /* Mobile: full-screen sheet for Add Sessions */
@media (max-width: 599px) { @media (max-width: 599px) {
.add-sessions-panel__content { .manage-view-panel__content {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
@@ -1958,6 +1965,66 @@ body {
} }
} }
.manage-view-panel__name-row {
display: flex;
align-items: center;
gap: 8px;
}
.manage-view-panel__name {
font-size: 15px;
font-weight: 600;
color: var(--text);
margin: 0;
cursor: pointer;
}
.manage-view-panel__name:hover::after {
content: ' \270E';
font-size: 12px;
color: var(--text-muted);
}
.manage-view-panel__summary {
font-size: 12px;
color: var(--text-muted);
margin: 4px 0 0;
}
.manage-view-panel__footer {
padding: 12px 16px;
border-top: 1px solid var(--border-subtle);
display: flex;
justify-content: flex-end;
}
.manage-view-panel__close-btn {
padding: 6px 16px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text);
font-size: 13px;
cursor: pointer;
}
.manage-view-panel__close-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.manage-view-panel__name-input {
font-size: 15px;
font-weight: 600;
color: var(--text);
background: var(--bg-secondary);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 2px 6px;
width: 200px;
max-width: 100%;
}
/* ============================================================ /* ============================================================
Responsive overlay sidebar at <960px Responsive overlay sidebar at <960px
============================================================ */ ============================================================ */
@@ -2195,6 +2262,25 @@ body {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
} }
.views-settings-btn--manage {
background: var(--bg-surface);
color: var(--accent);
border-color: var(--accent);
}
.views-settings-btn--manage:hover:not(:disabled) {
background: var(--accent-dim);
}
.views-settings-btn--new {
margin-top: 8px;
}
.views-settings-new-row {
padding: 8px 0 4px;
border-top: 1px solid var(--border-subtle);
}
.views-settings-confirm { .views-settings-confirm {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+62 -9
View File
@@ -2214,17 +2214,16 @@ def test_flyout_bottom_sheet_styled() -> None:
# ============================================================ # ============================================================
def test_add_sessions_panel_styled() -> None: def test_manage_view_panel_styled() -> None:
"""style.css must contain .add-sessions-panel styles.""" """style.css must contain .manage-view-panel styles (renamed from .add-sessions-panel)."""
css = read_css() css = read_css()
assert ".add-sessions-panel" in css, "style.css must style .add-sessions-panel" assert ".manage-view-panel" in css, "style.css must style .manage-view-panel"
def test_add_sessions_item_styled() -> None: def test_manage_view_item_styled() -> None:
"""style.css must contain .add-sessions-item styles.""" """style.css must contain .manage-view-item styles (renamed from .add-sessions-item)."""
css = read_css() css = read_css()
assert ".add-sessions-item" in css, "style.css must style .add-sessions-item" assert ".manage-view-item" in css, "style.css must style .manage-view-item"
assert ".add-sessions-item" in css, "style.css must style .add-sessions-item"
# ─── Phase 3 COE findings regression tests ────────────────────────────────── # ─── Phase 3 COE findings regression tests ──────────────────────────────────
@@ -2246,11 +2245,11 @@ def test_disclosure_not_hidden_by_css() -> None:
import re as _re import re as _re
match = _re.search( match = _re.search(
r"\.add-sessions-item__disclosure\s*\{([^}]*)\}", r"\.manage-view-item__disclosure\s*\{([^}]*)\}",
css, css,
_re.DOTALL, _re.DOTALL,
) )
assert match, ".add-sessions-item__disclosure rule not found in style.css" assert match, ".manage-view-item__disclosure rule not found in style.css"
body = match.group(1) body = match.group(1)
assert "display: none" not in body and "display:none" not in body.replace(" ", ""), ( assert "display: none" not in body and "display:none" not in body.replace(" ", ""), (
".add-sessions-item__disclosure must NOT have display:none — " ".add-sessions-item__disclosure must NOT have display:none — "
@@ -2309,3 +2308,57 @@ def test_no_sidebar_delete_css() -> None:
".sidebar-delete CSS rules must be removed from style.css — " ".sidebar-delete CSS rules must be removed from style.css — "
"the button was removed from buildSidebarHTML(); orphaned CSS is dead code" "the button was removed from buildSidebarHTML(); orphaned CSS is dead code"
) )
# ============================================================
# UX Overhaul CSS — Issues 3 and 8
# ============================================================
def test_manage_view_panel_styles_exist() -> None:
""".manage-view-panel CSS rule must exist (renamed from .add-sessions-panel).
Issue 3: The Add Sessions panel was redesigned as the Manage View panel.
All CSS classes must be renamed from add-sessions-* to manage-view-*.
"""
css = read_css()
assert ".manage-view-panel" in css, (
".manage-view-panel CSS rule must exist in style.css — "
"renamed from .add-sessions-panel (Issue 3)"
)
assert ".add-sessions-panel" not in css, (
".add-sessions-panel CSS rule must be removed — "
"renamed to .manage-view-panel (Issue 3)"
)
def test_device_badge_height_matches_flyout_button() -> None:
""".device-badge must have a height/line-height matching the ⋮ button (~22-24px).
Issue 8: The device badge was shorter than the ⋮ flyout button (24x24px),
making the tile header look uneven. Fix: increase .device-badge dimensions.
"""
import re
css = read_css()
# Find the BASE .device-badge rule (not a qualified selector like .parent .device-badge)
# We look for the standalone rule at the start of a line (or after whitespace)
match = re.search(r"(?:^|\n)\.device-badge\s*\{([^}]*)\}", css, re.DOTALL)
assert match, ".device-badge CSS rule not found"
rule = match.group(1)
# Should have a line-height or min-height that results in ~22-24px
# Check for either explicit height/min-height or a large enough line-height/padding
has_height = (
"min-height" in rule
or ("line-height" in rule and any(
f"line-height: {n}" in rule or f"line-height:{n}" in rule
for n in ["1.6", "1.7", "1.8", "1.9", "2", "22px", "23px", "24px"]
))
or ("padding" in rule and "padding: 2px" in rule)
or ("padding: 3px" in rule)
or ("padding: 4px" in rule)
)
assert has_height, (
".device-badge must have increased height to match the ~24px ⋮ button — "
"add min-height or increase padding/line-height so the badge is ~22-24px tall"
)
+39 -10
View File
@@ -1540,20 +1540,49 @@ def test_no_hidden_sessions_checkbox_list_in_settings() -> None:
# ============================================================ # ============================================================
def test_add_sessions_panel_exists() -> None: def test_manage_view_panel_exists() -> None:
"""index.html must contain an add-sessions-panel element.""" """index.html must contain a manage-view-panel element (renamed from add-sessions-panel)."""
soup = _SOUP soup = _SOUP
assert soup.find(id="add-sessions-panel"), ( assert soup.find(id="manage-view-panel"), (
"index.html must contain an element with id='add-sessions-panel'" "index.html must contain an element with id='manage-view-panel'"
) )
def test_add_sessions_panel_has_role_dialog() -> None: def test_manage_view_panel_has_role_dialog() -> None:
"""add-sessions-panel must have role='dialog' and aria-modal='true'.""" """manage-view-panel must have role='dialog' and aria-modal='true'."""
soup = _SOUP soup = _SOUP
panel = soup.find(id="add-sessions-panel") panel = soup.find(id="manage-view-panel")
assert panel, "Missing #add-sessions-panel" assert panel, "Missing #manage-view-panel"
assert panel.get("role") == "dialog", "add-sessions-panel must have role='dialog'" assert panel.get("role") == "dialog", "manage-view-panel must have role='dialog'"
assert panel.get("aria-modal") == "true", ( assert panel.get("aria-modal") == "true", (
"add-sessions-panel must have aria-modal='true'" "manage-view-panel must have aria-modal='true'"
)
def test_add_sessions_btn_removed() -> None:
"""index.html must NOT contain #add-sessions-btn (removed in Issue 5).
The '+ Add' header button was removed — Manage View is now accessed via
the dropdown's 'Manage [ViewName]...' item.
"""
soup = _SOUP
assert not soup.find(id="add-sessions-btn"), (
"index.html must NOT contain id='add-sessions-btn'"
"the button was removed; use the dropdown 'Manage [ViewName]...' item instead"
)
def test_kill_session_command_label() -> None:
"""Settings Commands tab must use 'Kill session command' not 'Delete session command'.
Terminology must match the flyout menu which uses 'Kill Session'.
"""
html = HTML_PATH.read_text()
assert "Kill session command" in html or "Kill Session Command" in html, (
"Settings Commands tab must use 'Kill session command'"
"terminology must match the flyout's 'Kill Session' action"
)
assert "Delete session command" not in html and "Delete Session Command" not in html, (
"Settings Commands tab must not use 'Delete session command'"
"rename to 'Kill session command' to match the flyout"
) )
+230 -89
View File
@@ -3502,8 +3502,11 @@ def test_delete_active_view_persists_active_view_to_server() -> None:
) )
def test_rename_active_view_persists_active_view_to_server() -> None: def test_views_settings_tab_no_inline_rename_commit() -> None:
"""When renaming the active view in renderViewsSettingsTab, PATCH /api/state with active_view.""" """renderViewsSettingsTab must NOT have inline rename (commitRename removed in Issue 6).
Rename now lives in the Manage View panel, not the settings tab.
"""
match = re.search( match = re.search(
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3511,15 +3514,10 @@ def test_rename_active_view_persists_active_view_to_server() -> None:
) )
assert match, "renderViewsSettingsTab function not found" assert match, "renderViewsSettingsTab function not found"
body = match.group(1) body = match.group(1)
# Find the commitRename block or rename path # Inline rename (commitRename) must be gone — it moved to the Manage View panel
rename_idx = body.find("commitRename") assert "commitRename" not in body, (
assert rename_idx >= 0, "commitRename not found in renderViewsSettingsTab" "renderViewsSettingsTab must not have inline commitRename — "
rename_block = body[rename_idx:] "rename moved to the Manage View panel (Issue 6)"
assert "api(" in rename_block, (
"renderViewsSettingsTab rename path must call api() to persist active_view"
)
assert "active_view" in rename_block, (
"renderViewsSettingsTab rename path must PATCH /api/state with active_view"
) )
@@ -3567,8 +3565,8 @@ def test_show_new_view_input_reserved_name_check_is_case_insensitive() -> None:
) )
def test_rename_reserved_name_check_is_case_insensitive() -> None: def test_rename_reserved_name_check_moved_to_manage_view_panel() -> None:
"""The rename commitRename reserved name check must use toLowerCase().""" """renderViewsSettingsTab must NOT contain commitRename — it moved to Manage View panel (Issue 6)."""
match = re.search( match = re.search(
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3576,19 +3574,17 @@ def test_rename_reserved_name_check_is_case_insensitive() -> None:
) )
assert match, "renderViewsSettingsTab function not found" assert match, "renderViewsSettingsTab function not found"
body = match.group(1) body = match.group(1)
rename_idx = body.find("commitRename") # commitRename was removed from settings tab — it now lives in openManageViewPanel
assert rename_idx >= 0, "commitRename not found in renderViewsSettingsTab" assert "commitRename" not in body, (
rename_block = body[rename_idx:] "renderViewsSettingsTab must not have commitRename — rename moved to Manage View panel (Issue 6)"
assert "toLowerCase" in rename_block, (
"commitRename reserved name check must use toLowerCase() — spec requires case-insensitive"
) )
# ─── Fix 8: rename input maxLength ─────────────────────────────────────────── # ─── Fix 8: rename input maxLength ───────────────────────────────────────────
def test_rename_input_has_max_length() -> None: def test_rename_input_not_in_settings_tab() -> None:
"""renderViewsSettingsTab rename input must have maxLength = 30.""" """renderViewsSettingsTab must NOT have views-settings-rename-input (Issue 6: inline rename removed)."""
match = re.search( match = re.search(
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3596,12 +3592,9 @@ def test_rename_input_has_max_length() -> None:
) )
assert match, "renderViewsSettingsTab function not found" assert match, "renderViewsSettingsTab function not found"
body = match.group(1) body = match.group(1)
rename_idx = body.find("views-settings-rename-input") assert "views-settings-rename-input" not in body, (
assert rename_idx >= 0, "rename input not found in renderViewsSettingsTab" "renderViewsSettingsTab must NOT have views-settings-rename-input — "
rename_block = body[rename_idx : rename_idx + 400] "inline rename was moved to the Manage View panel (Issue 6)"
assert "maxLength" in rename_block, (
"renderViewsSettingsTab rename input must set maxLength = 30 "
"(matches creation input constraint)"
) )
@@ -3889,40 +3882,40 @@ def test_do_kill_inline_shows_confirmation_buttons() -> None:
# ============================================================ # ============================================================
def test_open_add_sessions_panel_function_exists() -> None: def test_open_manage_view_panel_function_exists() -> None:
"""app.js must define an openAddSessionsPanel function.""" """app.js must define an openManageViewPanel function (renamed from openAddSessionsPanel)."""
assert "function openAddSessionsPanel" in _JS, ( assert "function openManageViewPanel" in _JS, (
"app.js must contain an openAddSessionsPanel function" "app.js must contain an openManageViewPanel function"
) )
def test_close_add_sessions_panel_function_exists() -> None: def test_close_manage_view_panel_function_exists() -> None:
"""app.js must define a closeAddSessionsPanel function.""" """app.js must define a closeManageViewPanel function (renamed from closeAddSessionsPanel)."""
assert "function closeAddSessionsPanel" in _JS, ( assert "function closeManageViewPanel" in _JS, (
"app.js must contain a closeAddSessionsPanel function" "app.js must contain a closeManageViewPanel function"
) )
def test_render_add_sessions_list_function_exists() -> None: def test_render_manage_view_list_function_exists() -> None:
"""app.js must define a renderAddSessionsList function.""" """app.js must define a renderManageViewList function (renamed from renderAddSessionsList)."""
assert "function renderAddSessionsList" in _JS, ( assert "function renderManageViewList" in _JS, (
"app.js must contain a renderAddSessionsList function" "app.js must contain a renderManageViewList function"
) )
def test_add_sessions_uses_immediate_commit() -> None: def test_manage_view_uses_immediate_commit() -> None:
"""renderAddSessionsList must PATCH immediately on checkbox change (no batch Done).""" """renderManageViewList must PATCH immediately on checkbox change (no batch Done)."""
fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0] fn_body = _JS.split("function renderManageViewList")[1].split("\nfunction ")[0]
assert "PATCH" in fn_body or "api(" in fn_body, ( assert "PATCH" in fn_body or "api(" in fn_body, (
"renderAddSessionsList must fire PATCH on each checkbox change (immediate commit)" "renderManageViewList must fire PATCH on each checkbox change (immediate commit)"
) )
def test_add_sessions_shows_device_name() -> None: def test_manage_view_shows_device_name() -> None:
"""renderAddSessionsList must show device name next to each session.""" """renderManageViewList must show device name next to each session."""
fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0] fn_body = _JS.split("function renderManageViewList")[1].split("\nfunction ")[0]
assert "deviceName" in fn_body or "device" in fn_body, ( assert "deviceName" in fn_body or "device" in fn_body, (
"renderAddSessionsList must show device name for disambiguation" "renderManageViewList must show device name for disambiguation"
) )
@@ -3947,23 +3940,22 @@ def test_open_flyout_menu_checks_mobile() -> None:
# ─── Task 11: Add Sessions entry point in grid ─────────────────────────────── # ─── Task 11: Add Sessions entry point in grid ───────────────────────────────
def test_render_grid_has_add_sessions_affordance() -> None: def test_render_view_dropdown_has_manage_view_affordance() -> None:
"""app.js must have an 'Add Sessions' affordance for user views. """renderViewDropdown must include a 'Manage [ViewName]...' action for user views.
The affordance moved from a tile in renderGrid to a header button (#add-sessions-btn) The affordance moved from a header button to the dropdown's 'Manage [ViewName]...' item.
managed by updateAddSessionsButton(). Either the old tile approach or the new header This item should open the Manage View panel for the current user view.
button approach satisfies this requirement.
""" """
fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0] match = re.search(
# Old approach: tile in the grid | New approach: header button via updateAddSessionsButton r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
has_affordance = ( _JS,
"add-sessions" in fn_body.lower() re.DOTALL,
or "openAddSessionsPanel" in fn_body
or "updateAddSessionsButton" in _JS
) )
assert has_affordance, ( assert match, "renderViewDropdown function not found"
"app.js must have an 'Add Sessions' affordance for user views — " body = match.group(1)
"either as a tile in renderGrid or as a header button via updateAddSessionsButton" assert "manage-view" in body, (
"renderViewDropdown must include a 'Manage [ViewName]...' action with data-action='manage-view' "
"for user views — this replaced the old #add-sessions-btn header button"
) )
@@ -4028,21 +4020,21 @@ def test_flyout_delegation_handler_no_stop_propagation() -> None:
# ── BUG 2: disclosure has no hover-based show/hide in renderAddSessionsList ── # ── BUG 2: disclosure has no hover-based show/hide in renderAddSessionsList ──
def test_render_add_sessions_list_no_hover_disclosure() -> None: def test_render_manage_view_list_no_hover_disclosure() -> None:
"""renderAddSessionsList must NOT have mouseover/mouseout handlers for disclosure. """renderManageViewList must NOT have mouseover/mouseout handlers for disclosure.
BUG: The disclosure was shown only on hover via JS mouseover/mouseout. This broke BUG: The disclosure was shown only on hover via JS mouseover/mouseout. This broke
on mobile (no hover) and was inconsistent. Fix: disclose statically — remove CSS on mobile (no hover) and was inconsistent. Fix: disclose statically — remove CSS
display:none and remove the hover handlers. The disclosure appears whenever the display:none and remove the hover handlers. The disclosure appears whenever the
hidden item is in the HTML (it's already conditionally rendered for hidden items). hidden item is in the HTML (it's already conditionally rendered for hidden items).
""" """
fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0] fn_body = _JS.split("function renderManageViewList")[1].split("\nfunction ")[0]
assert "onmouseover" not in fn_body, ( assert "onmouseover" not in fn_body, (
"renderAddSessionsList must not use onmouseover to show disclosure — " "renderManageViewList must not use onmouseover to show disclosure — "
"the disclosure must be statically visible (BUG 2 fix)" "the disclosure must be statically visible (BUG 2 fix)"
) )
assert "onmouseout" not in fn_body, ( assert "onmouseout" not in fn_body, (
"renderAddSessionsList must not use onmouseout to hide disclosure — " "renderManageViewList must not use onmouseout to hide disclosure — "
"the disclosure must be statically visible (BUG 2 fix)" "the disclosure must be statically visible (BUG 2 fix)"
) )
@@ -4349,8 +4341,8 @@ def test_render_view_dropdown_shows_user_view_session_count() -> None:
# — Issue 3: Empty new view opens Add Sessions panel ——————————————————— # — Issue 3: Empty new view opens Add Sessions panel ———————————————————
def test_show_new_view_input_calls_open_add_sessions_panel() -> None: def test_show_new_view_input_calls_open_manage_view_panel() -> None:
"""showNewViewInput must call openAddSessionsPanel after creating a new view.""" """showNewViewInput must call openManageViewPanel after creating a new view."""
match = re.search( match = re.search(
r"function showNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", r"function showNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
_JS, _JS,
@@ -4358,14 +4350,14 @@ def test_show_new_view_input_calls_open_add_sessions_panel() -> None:
) )
assert match, "showNewViewInput function not found" assert match, "showNewViewInput function not found"
body = match.group(1) body = match.group(1)
assert "openAddSessionsPanel" in body, ( assert "openManageViewPanel" in body, (
"showNewViewInput must call openAddSessionsPanel() after creating a new view — " "showNewViewInput must call openManageViewPanel() after creating a new view — "
"so the user immediately sees the Add Sessions panel for their empty view" "so the user immediately sees the Manage View panel for their empty view"
) )
def test_show_sidebar_new_view_input_calls_open_add_sessions_panel() -> None: def test_show_sidebar_new_view_input_calls_open_manage_view_panel() -> None:
"""showSidebarNewViewInput must call openAddSessionsPanel after creating a new view.""" """showSidebarNewViewInput must call openManageViewPanel after creating a new view."""
match = re.search( match = re.search(
r"function showSidebarNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", r"function showSidebarNewViewInput\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )",
_JS, _JS,
@@ -4373,8 +4365,8 @@ def test_show_sidebar_new_view_input_calls_open_add_sessions_panel() -> None:
) )
assert match, "showSidebarNewViewInput function not found" assert match, "showSidebarNewViewInput function not found"
body = match.group(1) body = match.group(1)
assert "openAddSessionsPanel" in body, ( assert "openManageViewPanel" in body, (
"showSidebarNewViewInput must call openAddSessionsPanel() after creating a new view" "showSidebarNewViewInput must call openManageViewPanel() after creating a new view"
) )
@@ -4417,16 +4409,16 @@ def test_open_flyout_submenu_new_view_creates_and_switches() -> None:
# — Issue 5: Add Sessions header button ———————————————————————————————— # — Issue 5: Add Sessions header button ————————————————————————————————
def test_update_add_sessions_button_function_exists() -> None: def test_update_add_sessions_button_removed() -> None:
"""updateAddSessionsButton function must exist in app.js.""" """updateAddSessionsButton must be REMOVED — the + Add button is gone (Issue 5)."""
assert "function updateAddSessionsButton" in _JS, ( assert "function updateAddSessionsButton" not in _JS, (
"updateAddSessionsButton must be defined in app.js — " "updateAddSessionsButton must be removed — the #add-sessions-btn header button "
"shows/hides the '+ Add' header button based on active view" "was removed and replaced by the 'Manage [ViewName]...' dropdown item"
) )
def test_switch_view_calls_update_add_sessions_button() -> None: def test_switch_view_no_update_add_sessions_button() -> None:
"""switchView must call updateAddSessionsButton to update header button visibility.""" """switchView must NOT call updateAddSessionsButton (it was removed in Issue 5)."""
match = re.search( match = re.search(
r"function switchView\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )", r"function switchView\s*\(\w+\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )",
_JS, _JS,
@@ -4434,22 +4426,22 @@ def test_switch_view_calls_update_add_sessions_button() -> None:
) )
assert match, "switchView function not found" assert match, "switchView function not found"
body = match.group(1) body = match.group(1)
assert "updateAddSessionsButton" in body, ( assert "updateAddSessionsButton" not in body, (
"switchView must call updateAddSessionsButton() to show/hide the header Add button" "switchView must not call updateAddSessionsButton() — it was removed in Issue 5"
) )
def test_render_grid_no_muxplex_app_onclick() -> None: def test_render_grid_no_muxplex_app_onclick() -> None:
"""renderGrid must not use the broken window.MuxplexApp.openAddSessionsPanel onclick.""" """renderGrid must not use the broken window.MuxplexApp.openManageViewPanel onclick."""
fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0] fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
assert "MuxplexApp.openAddSessionsPanel" not in fn_body, ( assert "MuxplexApp.openAddSessionsPanel" not in fn_body, (
"renderGrid must not use onclick='window.MuxplexApp.openAddSessionsPanel()'" "renderGrid must not use onclick='window.MuxplexApp.openAddSessionsPanel()'"
"this was broken; the Add Sessions entry point moved to the header button" "this was broken; the Manage View entry point moved to the dropdown"
) )
def test_bind_static_event_listeners_binds_add_sessions_btn() -> None: def test_bind_static_event_listeners_no_add_sessions_btn() -> None:
"""bindStaticEventListeners must bind #add-sessions-btn click to openAddSessionsPanel.""" """bindStaticEventListeners must NOT bind #add-sessions-btn (button was removed in Issue 5)."""
match = re.search( match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS, _JS,
@@ -4457,8 +4449,9 @@ def test_bind_static_event_listeners_binds_add_sessions_btn() -> None:
) )
assert match, "bindStaticEventListeners function not found" assert match, "bindStaticEventListeners function not found"
body = match.group(1) body = match.group(1)
assert "add-sessions-btn" in body, ( assert "add-sessions-btn" not in body, (
"bindStaticEventListeners must bind #add-sessions-btn click to openAddSessionsPanel" "bindStaticEventListeners must not bind #add-sessions-btn "
"the button was removed; Manage View is reached via the dropdown"
) )
@@ -4488,3 +4481,151 @@ def test_tile_options_btn_css_not_absolute() -> None:
".tile-options-btn must not use position:absolute — " ".tile-options-btn must not use position:absolute — "
"it should be an inline flex item inside tile-header to prevent device badge overlap" "it should be an inline flex item inside tile-header to prevent device badge overlap"
) )
# ============================================================
# UX Overhaul — 9 refinements from live testing
# ============================================================
# — Issue 1: Sidebar dropdown "New View" click-dismiss race ——————————————
def test_sidebar_click_outside_has_new_view_input_guard() -> None:
"""Sidebar click-outside handler must guard against new-view input dismiss race.
Race condition: clicking '+ New View' in the sidebar triggers the click-outside
handler before the input appears. Guard: check for .view-dropdown__new-input
presence and return early if found.
"""
match = re.search(
r"// Click-outside closes the sidebar view dropdown\s*\n\s*"
r"document\.addEventListener\('click',\s*function\(e\)\s*\{(.*?)\}\s*\);",
_JS,
re.DOTALL,
)
assert match, "Click-outside handler for sidebar view dropdown not found"
body = match.group(1)
assert ".view-dropdown__new-input" in body, (
"Sidebar click-outside handler must guard: "
"if (sidebarDropdown.querySelector('.view-dropdown__new-input')) return; "
"— prevents race where the new-view input is dismissed immediately"
)
# — Issue 4: Dropdown structure — "Manage [ViewName]…" ————————————————
def test_render_view_dropdown_has_manage_view_item_for_user_view() -> None:
"""renderViewDropdown must include 'Manage [ViewName]...' action 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 "manage-view" in body, (
"renderViewDropdown must include a 'Manage [ViewName]...' action "
"with data-action='manage-view' for user views"
)
def test_render_sidebar_dropdown_has_manage_view_item_for_user_view() -> None:
"""renderSidebarViewDropdown must include 'Manage [ViewName]...' action for user views."""
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 "manage-view" in body, (
"renderSidebarViewDropdown must include a 'Manage [ViewName]...' action "
"with data-action='manage-view' for user views"
)
# — Issue 5: No #add-sessions-btn + updateAddSessionsButton removed ————————
def test_domcontentloaded_no_update_add_sessions_button_call() -> None:
"""DOMContentLoaded handler must NOT call updateAddSessionsButton (it was removed)."""
assert "updateAddSessionsButton" not in _JS, (
"updateAddSessionsButton must be completely removed from app.js — "
"the #add-sessions-btn header button was replaced by the dropdown manage-view item"
)
# — Issue 6: Manage View settings tab ——————————————————————————————————
def test_views_settings_tab_has_manage_button_per_row() -> None:
"""renderViewsSettingsTab must include a 'Manage' button per view row."""
match = re.search(
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "renderViewsSettingsTab function not found"
body = match.group(1)
assert "manage-view" in body.lower() or "Manage" in body, (
"renderViewsSettingsTab must include a 'Manage' button per view row "
"that opens the Manage View panel for that view"
)
def test_views_settings_tab_has_new_view_button_at_bottom() -> None:
"""renderViewsSettingsTab must include a '+ New View' button at the bottom."""
match = re.search(
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "renderViewsSettingsTab function not found"
body = match.group(1)
assert "New View" in body or "new-view" in body, (
"renderViewsSettingsTab must include a '+ New View' button at the bottom"
)
def test_views_settings_tab_no_inline_rename_on_name_click() -> None:
"""renderViewsSettingsTab must NOT have click-to-rename on the name span.
Rename now lives in the Manage View panel, not in the settings tab.
"""
match = re.search(
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "renderViewsSettingsTab function not found"
body = match.group(1)
# The old inline rename code clicked on views-settings-name span to show an input
assert "views-settings-rename-input" not in body, (
"renderViewsSettingsTab must not have inline rename via views-settings-rename-input — "
"rename now lives in the Manage View panel"
)
# — Issue 7: Dropdown label fix on page reload —————————————————————————
def test_domcontentloaded_calls_render_view_dropdown_after_restore() -> None:
"""DOMContentLoaded must call renderViewDropdown() after restoreState() completes.
Bug: restoreState() sets _activeView but the dropdown label stays as 'All Sessions'.
Fix: call renderViewDropdown() after restoreState() so the label reflects _activeView.
"""
# Find the DOMContentLoaded handler section (from DOMContentLoaded to end of file)
idx = _JS.find("document.addEventListener('DOMContentLoaded'")
assert idx >= 0, "DOMContentLoaded handler not found"
handler_section = _JS[idx:]
# restoreState must appear before renderViewDropdown in the handler
restore_idx = handler_section.find("restoreState()")
assert restore_idx >= 0, "restoreState call not found in DOMContentLoaded section"
after_restore = handler_section[restore_idx:]
assert "renderViewDropdown" in after_restore, (
"DOMContentLoaded must call renderViewDropdown() after restoreState() so the "
"dropdown label correctly reflects _activeView on page reload"
)