diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js
index 73289ad..4cad1de 100644
--- a/muxplex/frontend/app.js
+++ b/muxplex/frontend/app.js
@@ -1969,6 +1969,260 @@ function _executeKill(name, remoteId) {
});
}
+// ─── Add Sessions Panel ──────────────────────────────────────────────────────────────────────────
+
+/**
+ * Open the Add Sessions panel for the active user view.
+ * Only available for user views (not "All" or "Hidden").
+ */
+function openAddSessionsPanel() {
+ if (_activeView === 'all' || _activeView === 'hidden') return;
+
+ var panel = $('add-sessions-panel');
+ if (!panel) return;
+
+ // Update title
+ var titleEl = $('add-sessions-title');
+ if (titleEl) titleEl.textContent = 'Add Sessions to “' + _activeView + '”';
+
+ renderAddSessionsList();
+ panel.classList.remove('hidden');
+
+ // Close on backdrop click
+ var backdrop = $('add-sessions-backdrop');
+ if (backdrop) {
+ backdrop.onclick = closeAddSessionsPanel;
+ }
+
+ // Close button
+ var closeBtn = $('add-sessions-close');
+ if (closeBtn) {
+ closeBtn.onclick = closeAddSessionsPanel;
+ }
+}
+
+/**
+ * Close the Add Sessions panel.
+ */
+function closeAddSessionsPanel() {
+ var panel = $('add-sessions-panel');
+ if (panel) panel.classList.add('hidden');
+}
+
+/**
+ * Render the session list inside the Add Sessions panel.
+ * Shows all sessions NOT currently in the active view.
+ * Hidden sessions are shown dimmed with a "hidden" badge and disclosure text.
+ * Grouped by device, alphabetical within each group.
+ * Immediate-commit checkboxes — each change fires a PATCH immediately.
+ */
+function renderAddSessionsList() {
+ var listEl = $('add-sessions-list');
+ var emptyEl = $('add-sessions-empty');
+ if (!listEl) return;
+
+ var views = (_serverSettings && _serverSettings.views) || [];
+ var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
+
+ // Find the active view's session list
+ var activeViewObj = null;
+ for (var i = 0; i < views.length; i++) {
+ if (views[i].name === _activeView) {
+ activeViewObj = views[i];
+ break;
+ }
+ }
+ if (!activeViewObj) { listEl.innerHTML = ''; return; }
+ var viewSessions = activeViewObj.sessions || [];
+
+ // Get all real sessions (not status entries), excluding those already in the view
+ var allSessions = (_currentSessions || []).filter(function(s) {
+ return !s.status;
+ });
+
+ var notInView = allSessions.filter(function(s) {
+ var key = s.sessionKey || s.name;
+ return viewSessions.indexOf(key) === -1;
+ });
+
+ if (notInView.length === 0) {
+ listEl.innerHTML = '';
+ if (emptyEl) emptyEl.style.display = '';
+ return;
+ }
+ if (emptyEl) emptyEl.style.display = 'none';
+
+ // Sort: group by deviceName, alphabetical within each group
+ notInView.sort(function(a, b) {
+ var da = (_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 = '';
+ for (var j = 0; j < notInView.length; j++) {
+ var s = notInView[j];
+ var key = s.sessionKey || s.name;
+ var isHidden = hidden.indexOf(key) !== -1 || hidden.indexOf(s.name) !== -1;
+ var escapedName = escapeHtml(s.name || '');
+ var deviceName = escapeHtml(_getDeviceDisplayName(s) || '');
+
+ html += '';
+ if (isHidden) {
+ html += '
This will make it visible again.
';
+ }
+ }
+
+ listEl.innerHTML = html;
+
+ // Delegated change handler for immediate-commit checkboxes
+ // Each checkbox change fires api('PATCH', '/api/settings') immediately (no batch Done).
+ listEl.onchange = function(e) {
+ var cb = e.target.closest('.add-sessions-item__checkbox');
+ if (!cb) return;
+ var sessionKey = cb.dataset.sessionKey;
+ var isHiddenSession = cb.dataset.isHidden === '1';
+
+ if (cb.checked) {
+ var views = (_serverSettings && _serverSettings.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('Couldn’t 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;
+ }
+ };
+
+ // Show/hide disclosure on hover for hidden items
+ listEl.onmouseover = function(e) {
+ var item = e.target.closest('.add-sessions-item--hidden');
+ if (item) {
+ var disc = item.nextElementSibling;
+ if (disc && disc.classList.contains('add-sessions-item__disclosure')) {
+ disc.style.display = '';
+ }
+ }
+ };
+ listEl.onmouseout = function(e) {
+ var item = e.target.closest('.add-sessions-item--hidden');
+ if (item) {
+ var disc = item.nextElementSibling;
+ if (disc && disc.classList.contains('add-sessions-item__disclosure')) {
+ disc.style.display = 'none';
+ }
+ }
+ };
+}
+
+/**
+ * Get a human-readable display name for the device a session belongs to.
+ * Priority: friendly name → hostname → truncated device_id → empty string.
+ * @param {object} session
+ * @returns {string}
+ */
+function _getDeviceDisplayName(session) {
+ if (!session) return '';
+ if (session.device_name) return session.device_name;
+ if (session.deviceName) return session.deviceName;
+ if (session.hostname) return session.hostname;
+ if (session.device_id) return session.device_id.slice(0, 8);
+ return '';
+}
+
+/**
+ * Add a session to the active user view via PATCH.
+ * If the session is hidden, also unhide it.
+ * On error: show toast, revert checkbox.
+ * @param {string} sessionKey
+ * @param {boolean} unhideFirst
+ * @param {HTMLInputElement} checkbox
+ */
+function _addSessionToActiveView(sessionKey, unhideFirst, checkbox) {
+ var views = (_serverSettings && _serverSettings.views) || [];
+ var updatedViews = JSON.parse(JSON.stringify(views));
+
+ // Find active view and add session
+ for (var i = 0; i < updatedViews.length; i++) {
+ if (updatedViews[i].name === _activeView) {
+ var sessions = updatedViews[i].sessions || [];
+ if (sessions.indexOf(sessionKey) === -1) {
+ sessions.push(sessionKey);
+ }
+ updatedViews[i].sessions = sessions;
+ break;
+ }
+ }
+
+ var patch = { views: updatedViews };
+
+ if (unhideFirst) {
+ var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
+ var hiddenIdx = hidden.indexOf(sessionKey);
+ if (hiddenIdx !== -1) {
+ var updatedHidden = hidden.slice();
+ updatedHidden.splice(hiddenIdx, 1);
+ patch.hidden_sessions = updatedHidden;
+ }
+ }
+
+ api('PATCH', '/api/settings', patch)
+ .then(function() {
+ if (_serverSettings) {
+ _serverSettings.views = updatedViews;
+ if (patch.hidden_sessions) _serverSettings.hidden_sessions = patch.hidden_sessions;
+ }
+ // Re-render the list (session is now in the view, so it disappears from the list)
+ renderAddSessionsList();
+ // Refresh the grid behind the panel
+ renderGrid(_currentSessions || []);
+ })
+ .catch(function(err) {
+ showToast('Couldn’t save — try again');
+ if (checkbox) checkbox.checked = false;
+ console.warn('[_addSessionToActiveView] PATCH failed:', err);
+ });
+}
+
+
// ─── Notification permission ────────────────────────────────────────────────
/**
@@ -3758,6 +4012,10 @@ if (typeof module !== 'undefined' && module.exports) {
createNewSession,
// Kill session
killSession,
+ // Add Sessions panel
+ openAddSessionsPanel,
+ closeAddSessionsPanel,
+ renderAddSessionsList,
// Flyout menu
openFlyoutMenu,
closeFlyoutMenu,
diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py
index 22921ab..f5dedbd 100644
--- a/muxplex/tests/test_frontend_js.py
+++ b/muxplex/tests/test_frontend_js.py
@@ -3882,3 +3882,45 @@ def test_do_kill_inline_shows_confirmation_buttons() -> None:
assert "Yes" in fn_body and "No" in fn_body, (
"_doKillSessionInline must show 'Kill? [Yes] [No]' inline"
)
+
+
+# ============================================================
+# Add Sessions panel JS logic (task-9)
+# ============================================================
+
+
+def test_open_add_sessions_panel_function_exists() -> None:
+ """app.js must define an openAddSessionsPanel function."""
+ assert "function openAddSessionsPanel" in _JS, (
+ "app.js must contain an openAddSessionsPanel function"
+ )
+
+
+def test_close_add_sessions_panel_function_exists() -> None:
+ """app.js must define a closeAddSessionsPanel function."""
+ assert "function closeAddSessionsPanel" in _JS, (
+ "app.js must contain a closeAddSessionsPanel function"
+ )
+
+
+def test_render_add_sessions_list_function_exists() -> None:
+ """app.js must define a renderAddSessionsList function."""
+ assert "function renderAddSessionsList" in _JS, (
+ "app.js must contain a renderAddSessionsList function"
+ )
+
+
+def test_add_sessions_uses_immediate_commit() -> None:
+ """renderAddSessionsList must PATCH immediately on checkbox change (no batch Done)."""
+ fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0]
+ assert "PATCH" in fn_body or "api(" in fn_body, (
+ "renderAddSessionsList must fire PATCH on each checkbox change (immediate commit)"
+ )
+
+
+def test_add_sessions_shows_device_name() -> None:
+ """renderAddSessionsList must show device name next to each session."""
+ fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0]
+ assert "deviceName" in fn_body or "device" in fn_body, (
+ "renderAddSessionsList must show device name for disambiguation"
+ )