` +
``
);
}
/**
* Build the HTML string for a single session sidebar card.
* @param {object} session
* @param {string} currentSession - name of the currently active session
* @returns {string}
*/
function buildSidebarHTML(session, currentSession) {
const name = session.name || '';
const escapedName = escapeHtml(name);
const isActive = name === currentSession;
var ds = getDisplaySettings();
var actIndicator = ds.activityIndicator !== undefined ? ds.activityIndicator : 'both';
const unseen = session.bell && session.bell.unseen_count;
const isBell = unseen && unseen > 0;
let classes = 'sidebar-item';
if (isActive) classes += ' sidebar-item--active';
// Glow (full border + inner glow): applied when actIndicator is 'glow' or 'both'
if (isBell && (actIndicator === 'glow' || actIndicator === 'both')) classes += ' sidebar-item--bell';
// Edge bar only (left border amber, no glow): applied when actIndicator is 'dot' or 'both'
if (isBell && (actIndicator === 'dot' || actIndicator === 'both')) classes += ' sidebar-item--edge-bell';
// Device badge — shown in header line when multi_device_enabled
let badgeHtml = '';
if (_serverSettings && _serverSettings.multi_device_enabled && session.deviceName && ds.showDeviceBadges !== false) {
badgeHtml = `${escapeHtml(session.deviceName)}`;
}
// Last 20 lines of snapshot — trim trailing blanks from the FULL snapshot FIRST,
// then slice. Sessions with the cursor near the top have content at rows 1-2 and
// rows 3-40 blank; slice(-20) would return only blank rows, then trim-after-slice
// removes everything → empty preview. Trim first to keep meaningful content.
const snapshot = session.snapshot || '';
var allLines = snapshot.split('\n');
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
allLines.pop();
}
const lastLines = allLines.slice(-20).join('\n');
// Prefer deviceId (device_id string from backend) over legacy integer remoteId
var _sidebarEffRemoteId = session.deviceId != null ? session.deviceId : (session.remoteId != null ? session.remoteId : '');
return (
`` +
`
` +
`${escapedName}` +
badgeHtml +
`
` +
`
${ansiToHtml(lastLines)}
` +
``
);
}
/**
* Build the HTML string for a generic status tile (auth_failed or unreachable).
* @param {string} deviceName
* @param {string} statusText
* @param {string} statusClass
* @returns {string}
*/
function buildStatusTileHTML(deviceName, statusText, statusClass) {
return (
'' +
'' + escapeHtml(deviceName || '') + '' +
'' + escapeHtml(statusText || '') + '' +
''
);
}
/**
* Returns sessions filtered by the active view.
*
* - 'all' view: excludes hidden sessions (sessions in hidden_sessions list)
* - 'hidden' view: shows only hidden sessions
* - user view: shows only sessions whose sessionKey is in that view's sessions list
*
* Status entries (unreachable, auth_failed, empty) are always excluded —
* they are rendered separately as status tiles.
*
* Falls back to 'all' behaviour if the active view no longer exists.
*
* @param {object[]} sessions
* @returns {object[]}
*/
function getVisibleSessions(sessions) {
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
var views = (_serverSettings && _serverSettings.views) || [];
var view = _resolveActiveView(_activeView, views);
return (sessions || []).filter(function(s) {
// Skip status entries (unreachable, auth_failed, empty) — rendered separately as status tiles
if (s.status) return false;
if (view === 'hidden') {
// 'hidden' view: only show sessions that are in the hidden list
return hidden.length > 0 && (hidden.includes(s.sessionKey || s.name) || hidden.includes(s.name));
}
if (view !== 'all') {
// User-defined view: show only sessions whose sessionKey is in this view's sessions list
var userView = views.find(function(v) { return v.name === view; });
if (userView) {
var viewSessions = userView.sessions || [];
return viewSessions.includes(s.sessionKey || s.name) || viewSessions.includes(s.name);
}
// View no longer exists — fall through to 'all' behaviour
}
// 'all' view: exclude hidden sessions
if (hidden.length > 0 && (hidden.includes(s.sessionKey || s.name) || hidden.includes(s.name))) {
return false;
}
return true;
});
}
/**
* Resolve the active view name against the known views list.
*
* If active_view is "all" or "hidden" it is always valid and returned as-is.
* If active_view matches a view name in the views list it is returned as-is.
* Otherwise (e.g. the view was deleted while this device was offline) fall back
* to "all" so the user always sees sessions rather than an empty/broken state.
*
* @param {string} activeView - The stored active_view value from state.
* @param {object[]} views - The views array from settings (each has a .name field).
* @returns {string} Resolved view name — always "all", "hidden", or a known view name.
*/
function _resolveActiveView(activeView, views) {
if (activeView === 'all' || activeView === 'hidden') return activeView;
var list = views || [];
for (var i = 0; i < list.length; i++) {
if (list[i].name === activeView) return activeView;
}
return 'all';
}
/**
* Render the session sidebar list. Only renders in fullscreen view.
* Shows empty state when no sessions exist.
* Binds click handlers on each sidebar-item to switch sessions.
* @param {object[]} sessions
* @param {string|null} currentSession - name of the currently active session
*/
function renderSidebar(sessions, currentSession) {
if (_viewMode !== 'fullscreen') return;
const list = $('sidebar-list');
if (!list) return;
const visible = getVisibleSessions(sessions);
if (visible.length === 0) {
list.innerHTML = '
No sessions
';
return;
}
let html = '';
if (_serverSettings && _serverSettings.multi_device_enabled) {
// Group sessions by deviceName when multi_device_enabled
const groups = new Map();
for (const session of visible) {
const deviceName = session.deviceName || 'Unknown';
if (!groups.has(deviceName)) groups.set(deviceName, []);
groups.get(deviceName).push(session);
}
for (const [deviceName, deviceSessions] of groups) {
html += `
${escapeHtml(deviceName)}
`;
html += deviceSessions.map((session) => buildSidebarHTML(session, currentSession)).join('');
}
} else {
// Single source: flat list with no device headers
html = visible.map((session) => buildSidebarHTML(session, currentSession)).join('');
}
list.innerHTML = html;
// Bind click handlers on each sidebar item, passing remoteId
if (typeof list.querySelectorAll === 'function') {
list.querySelectorAll('.sidebar-item').forEach((item) => {
const name = item.dataset.session;
const remoteId = item.dataset.remoteId || '';
on(item, 'click', (e) => {
if (name !== currentSession) openSession(name, { remoteId });
});
});
}
}
const SIDEBAR_NARROW_THRESHOLD = 960;
/**
* Initialise sidebar open/closed state on page load.
* Reads sidebarOpen from _serverSettings cache.
* Defaults to open on wide screens (innerWidth >= 960) when no stored value.
* Applies sidebar--collapsed class accordingly and persists the initial state.
*/
function initSidebar() {
var stored = _serverSettings ? _serverSettings.sidebarOpen : null;
var isOpen;
if (stored !== null && stored !== undefined) {
isOpen = !!stored;
} else {
isOpen = window.innerWidth >= SIDEBAR_NARROW_THRESHOLD;
// Persist the auto-detected value (fire-and-forget)
if (_serverSettings) _serverSettings.sidebarOpen = isOpen;
patchServerSetting('sidebarOpen', isOpen);
}
var sidebar = $('session-sidebar');
if (sidebar) {
if (isOpen) {
sidebar.classList.remove('sidebar--collapsed');
} else {
sidebar.classList.add('sidebar--collapsed');
}
}
}
/**
* Toggle the sidebar open/closed state.
* Derives current state from DOM class, inverts it, persists to server,
* applies sidebar--collapsed class, and updates the collapse button text.
* Button shows ‹ when open, › when closed.
*/
function toggleSidebar() {
var sidebar = $('session-sidebar');
if (!sidebar) return;
var isOpen = !sidebar.classList.contains('sidebar--collapsed');
isOpen = !isOpen;
if (isOpen) {
sidebar.classList.remove('sidebar--collapsed');
} else {
sidebar.classList.add('sidebar--collapsed');
}
if (_serverSettings) _serverSettings.sidebarOpen = isOpen;
patchServerSetting('sidebarOpen', isOpen);
var collapseBtn = $('sidebar-collapse-btn');
if (collapseBtn) collapseBtn.textContent = isOpen ? '\u2039' : '\u203a';
}
/**
* Bind a click-away handler on #terminal-container that collapses the sidebar
* when the user taps outside of it in overlay mode (window.innerWidth < 960).
* Returns early without collapsing if:
* - the screen is wide enough that the sidebar is not in overlay mode (>= 960px)
* - the sidebar element is missing
* - the sidebar is already collapsed
*/
function bindSidebarClickAway() {
var container = $('terminal-container');
if (!container) return;
container.addEventListener('click', function() {
if (window.innerWidth >= SIDEBAR_NARROW_THRESHOLD) return;
var sidebar = $('session-sidebar');
if (!sidebar) return;
if (sidebar.classList.contains('sidebar--collapsed')) return;
sidebar.classList.add('sidebar--collapsed');
if (_serverSettings) _serverSettings.sidebarOpen = false;
patchServerSetting('sidebarOpen', false);
});
}
/**
* Render the session grid. Shows empty state when no sessions exist.
* On mobile, sorts sessions by priority before rendering.
* Binds click and keydown handlers on each tile.
* @param {object[]} sessions
*/
/**
* Render sessions grouped by device name. Returns HTML string.
* @param {object[]} sessions - sorted, visible sessions
* @param {boolean} mobile
* @returns {string}
*/
function renderGroupedGrid(sessions, mobile) {
// Group by deviceName
var groups = {};
var groupOrder = [];
for (var i = 0; i < sessions.length; i++) {
var dn = sessions[i].deviceName || 'Unknown';
if (!groups[dn]) {
groups[dn] = [];
groupOrder.push(dn);
}
groups[dn].push(sessions[i]);
}
var html = '';
for (var g = 0; g < groupOrder.length; g++) {
var name = groupOrder[g];
html += '
' + escapeHtml(name) + '
';
var groupSessions = groups[name];
for (var j = 0; j < groupSessions.length; j++) {
html += buildTileHTML(groupSessions[j], j, mobile);
}
}
return html;
}
/**
* Render the filter pill bar into the given container element.
* Generates one 'All' pill plus one pill per unique device name found in allSessions.
* The currently active device pill is marked with the `filter-pill--active` class.
* @param {Element} container - The DOM element to render pills into.
* @param {Array} allSessions - Full (unfiltered) session list used to derive device names.
*/
function renderFilterBar(container, allSessions) {
// Dead code: filter bar replaced by Views feature. Kept as empty stub for export compatibility.
}
// ---------------------------------------------------------------------------
// View dropdown — render, open/close, view switching
// ---------------------------------------------------------------------------
/**
* Populate #view-dropdown-menu with the full view list and update the label.
* Called on open and after a view switch.
*/
function renderViewDropdown() {
var menu = $('view-dropdown-menu');
if (!menu) return;
var views = (_serverSettings && _serverSettings.views) || [];
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
var hiddenCount = hidden.length;
var html = '';
// — All Sessions (always first) — show count of non-hidden sessions
var allHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || [];
var allCount = (_currentSessions || []).filter(function(s) {
if (s.status) return false;
return allHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && allHiddenSessions.indexOf(s.name) === -1;
}).length;
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
html += '';
// — User views
if (views.length > 0) {
html += '';
for (var i = 0; i < views.length && i < 7; i++) {
var v = views[i];
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
html += '';
}
}
// — Hidden (N) (always last system view)
html += '';
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
html += '';
// — Actions (stronger separator)
html += '';
// 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 += '';
}
html += '';
html += '';
menu.innerHTML = html;
// Update the label
var label = $('view-dropdown-label');
if (label) {
if (_activeView === 'all') {
label.textContent = 'All Sessions';
} else if (_activeView === 'hidden') {
label.textContent = 'Hidden';
} else {
label.textContent = _activeView;
}
}
}
/**
* Toggle the view dropdown open/closed.
* Calls renderViewDropdown() when opening to ensure fresh content.
*/
function toggleViewDropdown() {
var menu = $('view-dropdown-menu');
var trigger = $('view-dropdown-trigger');
if (!menu) return;
var isOpen = !menu.classList.contains('hidden');
if (isOpen) {
closeViewDropdown();
} else {
menu.classList.remove('hidden');
if (trigger) trigger.setAttribute('aria-expanded', 'true');
renderViewDropdown();
}
}
/**
* Close the view dropdown. Removes inline new-view input if present.
*/
function closeViewDropdown() {
var menu = $('view-dropdown-menu');
var trigger = $('view-dropdown-trigger');
if (menu) {
menu.classList.add('hidden');
// Remove any inline new-view input
var newViewInput = menu.querySelector('.view-dropdown__new-input');
if (newViewInput) newViewInput.remove();
}
if (trigger) trigger.setAttribute('aria-expanded', 'false');
}
/**
* Render the sidebar view dropdown menu (same data as the header dropdown,
* but no action buttons — navigation only).
*/
function renderSidebarViewDropdown() {
var menu = $('sidebar-view-dropdown-menu');
if (!menu) return;
var views = (_serverSettings && _serverSettings.views) || [];
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
var hiddenCount = hidden.length;
var html = '';
// — All Sessions (always first) — show count of non-hidden sessions
var sbHiddenSessions = (_serverSettings && _serverSettings.hidden_sessions) || [];
var sbAllCount = (_currentSessions || []).filter(function(s) {
if (s.status) return false;
return sbHiddenSessions.indexOf(s.sessionKey || s.name) === -1 && sbHiddenSessions.indexOf(s.name) === -1;
}).length;
var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
html += '';
// — User views
if (views.length > 0) {
html += '';
for (var i = 0; i < views.length && i < 7; i++) {
var v = views[i];
var vActive = _activeView === v.name ? ' view-dropdown__item--active' : '';
html += '';
}
}
// — Hidden (N) (always last system view)
html += '';
var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
html += '';
// — Actions (stronger separator)
html += '';
// 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 += '';
}
html += '';
html += '';
menu.innerHTML = html;
}
/**
* Toggle the sidebar view dropdown open/closed.
* Calls renderSidebarViewDropdown() when opening to ensure fresh content.
*/
function toggleSidebarViewDropdown() {
var menu = $('sidebar-view-dropdown-menu');
var trigger = $('sidebar-view-dropdown-trigger');
if (!menu) return;
var isOpen = !menu.classList.contains('hidden');
if (isOpen) {
menu.classList.add('hidden');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
} else {
// Position with fixed coordinates to escape sidebar overflow:hidden clipping
if (trigger) {
var rect = trigger.getBoundingClientRect();
menu.style.top = (rect.bottom + 2) + 'px';
menu.style.left = rect.left + 'px';
}
menu.classList.remove('hidden');
if (trigger) trigger.setAttribute('aria-expanded', 'true');
renderSidebarViewDropdown();
}
}
/**
* Show an inline text input inside the view dropdown for creating a new view.
* Replaces the '+ New View' button with a text input inside the dropdown menu.
* - Removes any existing input and re-focuses it if already present.
* - On Enter: validates name (not empty, not reserved, not duplicate),
* then PATCHes /api/settings with the new view appended to views,
* updates _serverSettings.views on success, and calls switchView(name).
* - On Escape: closes the dropdown.
* - On blur: closes the dropdown after 150ms if input is no longer focused.
*/
function showNewViewInput() {
var menu = $('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();
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;
switchView(name);
openManageViewPanel();
})
.catch(function() {
showToast('Failed to create view');
});
} else if (e.key === 'Escape') {
closeViewDropdown();
}
});
input.addEventListener('blur', function() {
setTimeout(function() {
if (document.activeElement !== input) {
closeViewDropdown();
}
}, 150);
});
}
/**
* 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 + openManageViewPanel.
* - 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);
openManageViewPanel();
})
.catch(function() {
showToast('Failed to create view');
});
} else if (e.key === 'Escape') {
closeSidebarDropdown();
}
});
input.addEventListener('blur', function() {
setTimeout(function() {
if (document.activeElement !== input) {
closeSidebarDropdown();
}
}, 150);
});
}
/**
* Save updated views array via PATCH /api/settings, update _serverSettings,
* re-render the views settings tab, and re-render the view dropdown.
* @param {Array} updatedViews - New views array to save.
*/
function _saveViewsAndRerender(updatedViews) {
return api('PATCH', '/api/settings', { views: updatedViews })
.then(function() {
if (_serverSettings) _serverSettings.views = updatedViews;
renderViewsSettingsTab();
renderViewDropdown();
})
.catch(function() {
showToast('Failed to save views');
});
}
/**
* Render the Views settings tab content.
* Reads views from _serverSettings and builds an interactive list
* with inline rename, up/down reorder, and delete with confirmation.
*/
function renderViewsSettingsTab() {
var listEl = $('views-settings-list');
var emptyEl = $('views-settings-empty');
if (!listEl) return;
var views = (_serverSettings && _serverSettings.views) || [];
if (views.length === 0) {
listEl.innerHTML = '';
if (emptyEl) emptyEl.style.display = '';
return;
}
if (emptyEl) emptyEl.style.display = 'none';
// Build the list of view rows (no inline rename — rename is in Manage View panel)
listEl.innerHTML = '';
views.forEach(function(view, idx) {
var viewSessions = view.sessions || [];
var sessionCount = viewSessions.length;
var row = document.createElement('div');
row.className = 'views-settings-row';
row.setAttribute('data-view-idx', String(idx));
// Name span (not clickable for rename — rename is in Manage View panel)
var nameSpan = document.createElement('span');
nameSpan.className = 'views-settings-name';
nameSpan.textContent = view.name;
// Session count
var countSpan = document.createElement('span');
countSpan.className = 'views-settings-count';
countSpan.textContent = sessionCount + (sessionCount === 1 ? ' session' : ' sessions');
// Actions container
var actionsDiv = document.createElement('div');
actionsDiv.className = 'views-settings-actions';
// Up button
var upBtn = document.createElement('button');
upBtn.className = 'views-settings-btn';
upBtn.textContent = '\u25b2';
upBtn.title = 'Move up';
upBtn.setAttribute('data-action', 'move-up');
upBtn.setAttribute('data-idx', String(idx));
if (idx === 0) upBtn.disabled = true;
// Down button
var downBtn = document.createElement('button');
downBtn.className = 'views-settings-btn';
downBtn.textContent = '\u25bc';
downBtn.title = 'Move down';
downBtn.setAttribute('data-action', 'move-down');
downBtn.setAttribute('data-idx', String(idx));
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
var deleteBtn = document.createElement('button');
deleteBtn.className = 'views-settings-btn views-settings-btn--danger';
deleteBtn.textContent = 'Delete';
deleteBtn.setAttribute('data-action', 'delete');
deleteBtn.setAttribute('data-idx', String(idx));
actionsDiv.appendChild(upBtn);
actionsDiv.appendChild(downBtn);
actionsDiv.appendChild(manageBtn);
actionsDiv.appendChild(deleteBtn);
row.appendChild(nameSpan);
row.appendChild(countSpan);
row.appendChild(actionsDiv);
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
listEl.onclick = function(e) {
var views = (_serverSettings && _serverSettings.views) || [];
var target = e.target;
// Move up
if (target.getAttribute('data-action') === 'move-up') {
var idx = parseInt(target.getAttribute('data-idx'), 10);
if (idx > 0) {
var updated = views.slice();
var tmp = updated[idx - 1];
updated[idx - 1] = updated[idx];
updated[idx] = tmp;
_saveViewsAndRerender(updated);
}
return;
}
// Move down
if (target.getAttribute('data-action') === 'move-down') {
var idx = parseInt(target.getAttribute('data-idx'), 10);
if (idx < views.length - 1) {
var updated = views.slice();
var tmp = updated[idx + 1];
updated[idx + 1] = updated[idx];
updated[idx] = tmp;
_saveViewsAndRerender(updated);
}
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
if (target.getAttribute('data-action') === 'delete') {
var idx = parseInt(target.getAttribute('data-idx'), 10);
var row = listEl.querySelector('[data-view-idx="' + idx + '"]');
if (!row) return;
// Replace delete button with "Sure? [Yes] [No]"
var actionsDiv = row.querySelector('.views-settings-actions');
if (!actionsDiv) return;
actionsDiv.innerHTML = '';
var confirmSpan = document.createElement('span');
confirmSpan.className = 'views-settings-confirm';
confirmSpan.textContent = 'Sure? ';
var yesBtn = document.createElement('button');
yesBtn.className = 'views-settings-btn views-settings-btn--danger';
yesBtn.textContent = 'Yes';
yesBtn.setAttribute('data-action', 'confirm-delete');
yesBtn.setAttribute('data-idx', String(idx));
var noBtn = document.createElement('button');
noBtn.className = 'views-settings-btn';
noBtn.textContent = 'No';
noBtn.setAttribute('data-action', 'cancel-delete');
confirmSpan.appendChild(yesBtn);
confirmSpan.appendChild(document.createTextNode(' '));
confirmSpan.appendChild(noBtn);
actionsDiv.appendChild(confirmSpan);
return;
}
// Confirm delete
if (target.getAttribute('data-action') === 'confirm-delete') {
var idx = parseInt(target.getAttribute('data-idx'), 10);
var updated = views.slice();
updated.splice(idx, 1);
// If deleting the active view, fall back to 'all'
if (_activeView === views[idx].name) {
_activeView = 'all';
api('PATCH', '/api/state', { active_view: _activeView }).catch(function() {});
}
_saveViewsAndRerender(updated);
return;
}
// Cancel delete: re-render
if (target.getAttribute('data-action') === 'cancel-delete') {
renderViewsSettingsTab();
return;
}
// + New View: create a new view and open Manage View panel
if (target.getAttribute('data-action') === 'new-view-in-settings') {
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;
}
if (views.find(function(v) { return v.name === newName; })) {
showToast('View \'' + newName + '\' already exists');
return;
}
var updatedViews = views.concat([{ name: newName, sessions: [] }]);
api('PATCH', '/api/settings', { views: updatedViews })
.then(function() {
if (_serverSettings) _serverSettings.views = updatedViews;
renderViewsSettingsTab();
renderViewDropdown();
// Close settings and open Manage View panel for the new view
closeSettings();
switchView(newName);
openManageViewPanel();
})
.catch(function() {
showToast('Failed to create view');
});
return;
}
};
}
/**
* Switch to a named view. Updates _activeView, re-renders the grid and sidebar,
* updates the dropdown label, and persists the change via PATCH /api/state.
* @param {string} viewName - 'all', 'hidden', or a user view name.
*/
function switchView(viewName) {
_activeView = viewName;
closeViewDropdown();
renderGrid(_currentSessions || []);
renderSidebar(_currentSessions || [], _viewingSession);
renderViewDropdown();
// Update sidebar view label to match the active view
var sidebarLabel = $('sidebar-view-label');
if (sidebarLabel) {
if (viewName === 'all') {
sidebarLabel.textContent = 'All Sessions';
} else if (viewName === 'hidden') {
sidebarLabel.textContent = 'Hidden';
} else {
sidebarLabel.textContent = viewName;
}
}
// Persist active view — fire and forget
api('PATCH', '/api/state', { active_view: viewName }).catch(function() {});
}
function renderGrid(sessions) {
var grid = $('session-grid');
var emptyState = $('empty-state');
var filterBar = $('filter-bar');
// Close flyout if the targeted session no longer exists
if (_flyoutSessionKey) {
var flyoutStillExists = (sessions || []).some(function(s) {
return (s.sessionKey || s.name) === _flyoutSessionKey;
});
if (!flyoutStillExists) {
closeFlyoutMenu();
}
}
var visible = getVisibleSessions(sessions);
if (visible.length === 0) {
// Build status tiles for auth_failed/unreachable sessions even when no regular sessions exist
var statusTilesHtml = '';
(sessions || []).forEach(function(session) {
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
});
if (grid) grid.innerHTML = statusTilesHtml;
// Only show empty-state when there are truly no tiles at all
if (emptyState) {
if (statusTilesHtml) emptyState.classList.add('hidden');
else emptyState.classList.remove('hidden');
}
if (filterBar) filterBar.innerHTML = '';
return;
}
if (emptyState) emptyState.classList.add('hidden');
// Apply sort order from server settings
var sortOrder = _serverSettings && _serverSettings.sort_order;
var mobile = isMobile();
var ordered;
if (sortOrder === 'alphabetical') {
ordered = visible.slice().sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
} else {
// 'recent', 'manual', and default use server-provided order; priority sort on mobile
ordered = mobile ? sortByPriority(visible) : visible;
}
var html;
if (_gridViewMode === 'grouped') {
html = renderGroupedGrid(ordered, mobile);
} else {
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
}
// Append status tiles for auth_failed, unreachable, and empty sessions
var statusTilesHtml = '';
(sessions || []).forEach(function(session) {
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
});
if (grid) grid.innerHTML = html + statusTilesHtml;
// Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped)
if (filterBar) filterBar.innerHTML = '';
// Bind interaction handlers on each tile
document.querySelectorAll('.session-tile').forEach(function(tile) {
on(tile, 'click', (e) => {
// Don't navigate when clicking the options button inside the tile
if (e.target.closest && e.target.closest('.tile-options-btn')) return;
// Don't open error/status tiles (unreachable, auth_failed)
if (tile.classList.contains('source-tile--error') || !tile.dataset.session) return;
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
});
on(tile, 'keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
// Don't open error/status tiles (unreachable, auth_failed)
if (tile.classList.contains('source-tile--error') || !tile.dataset.session) return;
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
}
});
});
if (_viewMode === 'fullscreen') {
updatePillBell();
}
// Reapply view mode layout after grid HTML is rebuilt
var currentDs = getDisplaySettings();
var currentMode = currentDs.viewMode || 'auto';
if (currentMode === 'fit' && grid) {
grid.classList.add('session-grid--fit');
applyFitLayout(grid);
}
}
// ---------------------------------------------------------------------------
// Hover preview popover (desktop only — no hover on touch devices)
// ---------------------------------------------------------------------------
// Click handler registered while preview is showing — navigates to the previewed session
function _previewClickHandler(e) {
e.preventDefault();
e.stopPropagation();
var name = _previewSessionName;
hidePreview();
if (name) {
var session = _currentSessions && _currentSessions.find(function(s) { return s.name === name; });
openSession(name, { remoteId: (session != null && session.remoteId != null) ? session.remoteId : '' });
}
}
function showPreview(name) {
if (!name || !_currentSessions) return;
var _previewDs = getDisplaySettings();
if (_previewDs.showHoverPreview === false) return;
var session = _currentSessions.find(function (s) { return s.name === name; });
if (!session || !session.snapshot) return;
// If already showing this session, just update content
if (_previewPopover && _previewSessionName === name) {
var pre = _previewPopover.querySelector('pre');
if (pre) pre.innerHTML = ansiToHtml(session.snapshot);
return;
}
hidePreviewDOM();
_previewSessionName = name;
// Full-window overlay
var popover = document.createElement('div');
popover.className = 'preview-popover';
var pre = document.createElement('pre');
pre.innerHTML = ansiToHtml(session.snapshot);
popover.appendChild(pre);
document.body.appendChild(popover);
_previewPopover = popover;
// Auto-scroll to bottom (prompt area)
popover.scrollTop = popover.scrollHeight;
// Click anywhere navigates to previewed session
document.addEventListener('click', _previewClickHandler, true);
}
// hidePreviewDOM: removes the visual elements only (no render trigger)
function hidePreviewDOM() {
document.removeEventListener('click', _previewClickHandler, true);
if (_previewPopover) {
_previewPopover.remove();
_previewPopover = null;
}
}
// hidePreview: full cleanup including timer and session name
function hidePreview() {
if (_previewTimer) {
clearTimeout(_previewTimer);
_previewTimer = null;
}
hidePreviewDOM();
_previewSessionName = null;
}
// ── Tile Flyout Menu ──────────────────────────────────────────────────────────
/**
* Open the flyout menu for a session tile's ⋮ button.
* Creates a floating menu appended to document.body, positioned relative to
* the trigger button via getBoundingClientRect. On mobile, renders as a
* bottom action sheet instead.
* @param {HTMLElement} triggerEl - The .tile-options-btn element that was clicked
*/
function openFlyoutMenu(triggerEl) {
closeFlyoutMenu();
// Read session info from the tile
var tile = triggerEl.closest('[data-session-key]');
if (!tile) return;
_flyoutSessionKey = tile.dataset.sessionKey || '';
_flyoutSessionName = tile.dataset.session || '';
_flyoutRemoteId = tile.dataset.remoteId || '';
if (isMobile()) {
_openFlyoutSheet();
return;
}
// Build menu items based on active view type
var menuHtml = _buildFlyoutMenuItems();
var menu = document.createElement('div');
menu.className = 'flyout-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Session options');
menu.innerHTML = menuHtml;
document.body.appendChild(menu);
_flyoutMenuEl = menu;
// Position relative to trigger
var rect = triggerEl.getBoundingClientRect();
var menuWidth = menu.offsetWidth;
var menuHeight = menu.offsetHeight;
// Default: below and to the left of the trigger
var top = rect.bottom + 4;
var left = rect.right - menuWidth;
// Keep within viewport
if (left < 8) left = 8;
if (top + menuHeight > window.innerHeight - 8) {
top = rect.top - menuHeight - 4;
}
if (top < 8) top = 8;
menu.style.top = top + 'px';
menu.style.left = left + 'px';
// Delegated click handler on the flyout
menu.addEventListener('click', _handleFlyoutClick);
// Close on click-outside (next tick to avoid the opening click)
setTimeout(function() {
document.addEventListener('click', _flyoutOutsideClickHandler, true);
}, 0);
}
/**
* Close the flyout menu and any open submenu.
*/
function closeFlyoutMenu() {
if (_flyoutSubmenuEl) {
_flyoutSubmenuEl.remove();
_flyoutSubmenuEl = null;
}
if (_flyoutMenuEl) {
_flyoutMenuEl.removeEventListener('click', _handleFlyoutClick);
_flyoutMenuEl.remove();
_flyoutMenuEl = null;
}
// Remove mobile sheet if open
var sheet = document.querySelector('.flyout-sheet');
if (sheet) sheet.remove();
document.removeEventListener('click', _flyoutOutsideClickHandler, true);
_flyoutSessionKey = null;
_flyoutSessionName = null;
_flyoutRemoteId = null;
}
/**
* Open a bottom action sheet for the flyout menu (mobile).
* Same actions as the desktop flyout, but renders as a full-width bottom sheet.
*/
function _openFlyoutSheet() {
var viewType = _activeView;
if (viewType !== 'all' && viewType !== 'hidden') viewType = 'user';
var items = FLYOUT_MENU_MAP[viewType] || FLYOUT_MENU_MAP['all'];
var html = '';
html += '
';
html += '';
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (item.separator) {
html += '';
continue;
}
var label = item.label;
if (label.indexOf('{viewName}') !== -1) {
var displayName = _activeView;
if (displayName.length > 20) displayName = displayName.substring(0, 20) + '\u2026';
label = label.replace('{viewName}', escapeHtml(displayName));
}
var cls = 'flyout-sheet__item';
if (item.className && item.className.indexOf('danger') !== -1) cls += ' flyout-sheet__item--danger';
html += '';
}
html += '
';
var sheet = document.createElement('div');
sheet.className = 'flyout-sheet';
sheet.setAttribute('role', 'dialog');
sheet.setAttribute('aria-modal', 'true');
sheet.innerHTML = html;
document.body.appendChild(sheet);
// Backdrop closes
var backdrop = sheet.querySelector('.flyout-sheet__backdrop');
if (backdrop) {
backdrop.addEventListener('click', closeFlyoutMenu);
}
// Delegated action handler
var panel = sheet.querySelector('.flyout-sheet__panel');
if (panel) {
panel.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.dataset.action;
if (action === 'add-to-view' || action === 'unhide-add-to-view') {
// On mobile, show a view picker sheet (not the Add Sessions panel which is for the active view)
var sessionKey = _flyoutSessionKey;
var sessionName = _flyoutSessionName;
var unhideFirst = action === 'unhide-add-to-view';
closeFlyoutMenu();
_openMobileViewPicker(sessionKey, sessionName, unhideFirst);
} else if (action === 'kill') {
// Show a confirmation sheet — consistent with the existing sheet pattern
var killName = _flyoutSessionName;
var killRemoteId = _flyoutRemoteId;
closeFlyoutMenu();
_openMobileKillConfirm(killName, killRemoteId);
} else {
// Dispatch directly
_handleFlyoutClick(e);
}
});
}
}
/**
* Show a confirmation bottom sheet before killing a session on mobile.
* Shows "Kill [sessionName]?" with Kill and Cancel buttons.
* @param {string} sessionName
* @param {string} remoteId
*/
function _openMobileKillConfirm(sessionName, remoteId) {
var sheet = document.createElement('div');
sheet.className = 'flyout-sheet';
var html = '';
html += '
';
html += '';
html += '
Kill ' + escapeHtml(sessionName) + '?
';
html += '';
html += '';
html += '
';
sheet.innerHTML = html;
document.body.appendChild(sheet);
var backdrop = sheet.querySelector('.flyout-sheet__backdrop');
if (backdrop) backdrop.addEventListener('click', function() { sheet.remove(); });
var panel = sheet.querySelector('.flyout-sheet__panel');
if (panel) {
panel.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
sheet.remove();
if (btn.dataset.action === 'confirm-kill') {
killSession(sessionName, remoteId);
}
});
}
}
/**
* Open a bottom sheet listing all user views for the session (mobile view picker).
* Same toggle behaviour as the desktop submenu — each tap fires a PATCH immediately.
* The sheet has a "Done" button that closes it.
* @param {string} sessionKey
* @param {string} sessionName
* @param {boolean} unhideFirst - If true, also unhide the session on first add
*/
function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) {
var views = (_serverSettings && _serverSettings.views) || [];
if (views.length === 0) {
showToast('No user views. Create one from the header dropdown.');
return;
}
var sheet = document.createElement('div');
sheet.className = 'flyout-sheet';
var html = '';
html += '
';
html += '';
for (var i = 0; i < views.length; i++) {
var v = views[i];
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
html += '';
}
html += '';
html += '';
html += '
';
sheet.innerHTML = html;
document.body.appendChild(sheet);
var backdrop = sheet.querySelector('.flyout-sheet__backdrop');
if (backdrop) backdrop.addEventListener('click', function() { sheet.remove(); });
var panel = sheet.querySelector('.flyout-sheet__panel');
if (panel) {
panel.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action="done"]');
if (btn) { sheet.remove(); return; }
var viewBtn = e.target.closest('[data-view-index]');
if (!viewBtn) return;
var idx = parseInt(viewBtn.dataset.viewIndex, 10);
var updatedViews = JSON.parse(JSON.stringify((_serverSettings && _serverSettings.views) || []));
var view = updatedViews[idx];
if (!view) return;
var sessions = view.sessions || [];
var pos = sessions.indexOf(sessionKey);
var nowIn;
if (pos !== -1) {
sessions.splice(pos, 1);
nowIn = false;
} else {
sessions.push(sessionKey);
nowIn = true;
}
view.sessions = sessions;
var patch = { views: updatedViews };
if (unhideFirst && nowIn) {
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;
unhideFirst = false; // only unhide on first successful add
}
}
// Update checkmark immediately for responsiveness
var checkEl = viewBtn.querySelector('span');
if (checkEl) checkEl.textContent = nowIn ? '\u2713' : '\u00a0\u00a0';
api('PATCH', '/api/settings', patch)
.then(function() {
if (_serverSettings) {
_serverSettings.views = updatedViews;
if (patch.hidden_sessions) _serverSettings.hidden_sessions = patch.hidden_sessions;
}
if (patch.hidden_sessions) renderGrid(_currentSessions || []);
})
.catch(function(err) {
showToast('Couldn\u2019t save \u2014 try again');
// Revert checkmark
if (checkEl) checkEl.textContent = nowIn ? '\u00a0\u00a0' : '\u2713';
console.warn('[_openMobileViewPicker] PATCH failed:', err);
});
});
}
}
/**
* Click-outside handler for the flyout menu.
* @param {MouseEvent} e
*/
function _flyoutOutsideClickHandler(e) {
if (_flyoutMenuEl && !_flyoutMenuEl.contains(e.target) &&
(!_flyoutSubmenuEl || !_flyoutSubmenuEl.contains(e.target))) {
closeFlyoutMenu();
}
}
/**
* Delegated click handler for the flyout menu.
* Dispatches based on data-action attribute.
* @param {MouseEvent} e
*/
function _handleFlyoutClick(e) {
var item = e.target.closest('[data-action]');
if (!item) return;
var action = item.dataset.action;
switch (action) {
case 'add-to-view':
case 'unhide-add-to-view':
_openFlyoutSubmenu(item, action === 'unhide-add-to-view');
break;
case 'remove-from-view':
_doRemoveFromView();
break;
case 'hide':
_doHideSession();
break;
case 'unhide':
_doUnhideSession();
break;
case 'kill':
_doKillSessionInline(item);
break;
default:
break;
}
}
/**
* Open the "Add to View" submenu next to a flyout menu item.
* Lists all user-created views with checkmarks for views the session is already in.
* Clicking a view toggles membership immediately via PATCH /api/settings.
* The flyout stays open after submenu actions.
* @param {HTMLElement} triggerItem - The menu item that triggered the submenu
* @param {boolean} unhideFirst - If true, also unhide the session (for "Unhide & Add to View")
*/
function _openFlyoutSubmenu(triggerItem, unhideFirst) {
// Close existing submenu
if (_flyoutSubmenuEl) {
_flyoutSubmenuEl.remove();
_flyoutSubmenuEl = null;
}
var views = (_serverSettings && _serverSettings.views) || [];
var sessionKey = _flyoutSessionKey;
// When in a user view, filter it out — the user already has "Remove from [ViewName]" for it
var isInUserView = _activeView !== 'all' && _activeView !== 'hidden';
var html = '';
for (var i = 0; i < views.length; i++) {
var v = views[i];
if (isInUserView && v.name === _activeView) continue;
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
html += '';
}
// — Always show "+ New View" option at the bottom
if (views.length > 0) {
html += '';
}
html += '';
var submenu = document.createElement('div');
submenu.className = 'flyout-submenu';
submenu.setAttribute('role', 'menu');
submenu.innerHTML = html;
document.body.appendChild(submenu);
_flyoutSubmenuEl = submenu;
// Position to the right of the trigger item (or left if no space)
if (_flyoutMenuEl) {
var menuRect = _flyoutMenuEl.getBoundingClientRect();
var subWidth = submenu.offsetWidth;
var subHeight = submenu.offsetHeight;
var itemRect = triggerItem.getBoundingClientRect();
var left = menuRect.right + 4;
if (left + subWidth > window.innerWidth - 8) {
left = menuRect.left - subWidth - 4;
}
var top = itemRect.top;
if (top + subHeight > window.innerHeight - 8) {
top = window.innerHeight - subHeight - 8;
}
if (top < 8) top = 8;
submenu.style.top = top + 'px';
submenu.style.left = left + 'px';
}
// Click handler — toggle view membership via PATCH /api/settings
submenu.addEventListener('click', function(e) {
// Handle '+ New View' action
var newViewAction = e.target.closest('[data-action="new-view-in-flyout"]');
if (newViewAction) {
var capturedKey = sessionKey;
var capturedUnhide = unhideFirst;
closeFlyoutMenu();
var newName = prompt('View name:');
if (!newName || !newName.trim()) return;
newName = newName.trim();
if (newName.toLowerCase() === 'all' || newName.toLowerCase() === 'hidden') {
showToast('Cannot use reserved name \'' + newName + '\'');
return;
}
var existViews = (_serverSettings && _serverSettings.views) || [];
if (existViews.find(function(v) { return v.name === newName; })) {
showToast('View \'' + newName + '\' already exists');
return;
}
var newView = { name: newName, sessions: [capturedKey] };
var newViews = existViews.concat([newView]);
var flyoutPatch = { views: newViews };
if (capturedUnhide) {
var hiddenList = (_serverSettings && _serverSettings.hidden_sessions) || [];
var hi = hiddenList.indexOf(capturedKey);
if (hi !== -1) {
var updHidden = hiddenList.slice();
updHidden.splice(hi, 1);
flyoutPatch.hidden_sessions = updHidden;
}
}
api('PATCH', '/api/settings', flyoutPatch)
.then(function() {
if (_serverSettings) {
_serverSettings.views = newViews;
if (flyoutPatch.hidden_sessions) _serverSettings.hidden_sessions = flyoutPatch.hidden_sessions;
}
switchView(newName);
})
.catch(function() {
showToast('Failed to create view');
});
return;
}
var btn = e.target.closest('[data-view-index]');
if (!btn) return;
var idx = parseInt(btn.dataset.viewIndex, 10);
var updatedViews = JSON.parse(JSON.stringify((_serverSettings && _serverSettings.views) || []));
var view = updatedViews[idx];
if (!view) return;
var sessions = view.sessions || [];
var pos = sessions.indexOf(sessionKey);
if (pos !== -1) {
sessions.splice(pos, 1);
} else {
sessions.push(sessionKey);
}
view.sessions = sessions;
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;
}
// Update checkmarks in submenu
if (_flyoutSubmenuEl) {
var checkItems = _flyoutSubmenuEl.querySelectorAll('[data-view-index]');
for (var ci = 0; ci < checkItems.length; ci++) {
var vi = parseInt(checkItems[ci].dataset.viewIndex, 10);
var checkEl = checkItems[ci].querySelector('.flyout-submenu__check');
if (checkEl && updatedViews[vi]) {
checkEl.textContent = (updatedViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? '\u2713' : '';
}
}
}
if (unhideFirst) {
renderGrid(_currentSessions || []);
}
})
.catch(function(err) {
showToast('Couldn\u2019t save \u2014 try again');
console.warn('[_openFlyoutSubmenu] PATCH failed:', err);
});
});
}
/**
* Hide a session: add to hidden_sessions and remove from ALL views.
* Closes the flyout and re-renders the grid.
*/
function _doHideSession() {
var sessionKey = _flyoutSessionKey;
if (!sessionKey) return;
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
var views = (_serverSettings && _serverSettings.views) || [];
// Add to hidden_sessions
var updatedHidden = hidden.slice();
if (updatedHidden.indexOf(sessionKey) === -1) {
updatedHidden.push(sessionKey);
}
// Remove from all views (mutual exclusion)
var updatedViews = JSON.parse(JSON.stringify(views));
for (var i = 0; i < updatedViews.length; i++) {
var sessions = updatedViews[i].sessions || [];
var idx = sessions.indexOf(sessionKey);
if (idx !== -1) sessions.splice(idx, 1);
}
closeFlyoutMenu();
api('PATCH', '/api/settings', { hidden_sessions: updatedHidden, views: updatedViews })
.then(function() {
if (_serverSettings) {
_serverSettings.hidden_sessions = updatedHidden;
_serverSettings.views = updatedViews;
}
renderGrid(_currentSessions || []);
renderViewDropdown();
})
.catch(function(err) {
showToast('Couldn\u2019t save \u2014 try again');
console.warn('[_doHideSession] PATCH failed:', err);
});
}
/**
* Unhide a session: remove from hidden_sessions.
* Closes the flyout and re-renders the grid.
*/
function _doUnhideSession() {
var sessionKey = _flyoutSessionKey;
if (!sessionKey) return;
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
var idx = hidden.indexOf(sessionKey);
if (idx === -1) { closeFlyoutMenu(); return; }
var updatedHidden = hidden.slice();
updatedHidden.splice(idx, 1);
closeFlyoutMenu();
api('PATCH', '/api/settings', { hidden_sessions: updatedHidden })
.then(function() {
if (_serverSettings) _serverSettings.hidden_sessions = updatedHidden;
renderGrid(_currentSessions || []);
renderViewDropdown();
})
.catch(function(err) {
showToast('Couldn\u2019t save \u2014 try again');
console.warn('[_doUnhideSession] PATCH failed:', err);
});
}
/**
* Remove a session from the currently active user view.
* Closes the flyout and re-renders the grid.
*/
function _doRemoveFromView() {
var sessionKey = _flyoutSessionKey;
if (!sessionKey || _activeView === 'all' || _activeView === 'hidden') return;
var views = (_serverSettings && _serverSettings.views) || [];
var updatedViews = JSON.parse(JSON.stringify(views));
// Find the active view and remove the session
for (var i = 0; i < updatedViews.length; i++) {
if (updatedViews[i].name === _activeView) {
var sessions = updatedViews[i].sessions || [];
var idx = sessions.indexOf(sessionKey);
if (idx !== -1) sessions.splice(idx, 1);
break;
}
}
closeFlyoutMenu();
api('PATCH', '/api/settings', { views: updatedViews })
.then(function() {
if (_serverSettings) _serverSettings.views = updatedViews;
renderGrid(_currentSessions || []);
})
.catch(function(err) {
showToast('Couldn\u2019t save \u2014 try again');
console.warn('[_doRemoveFromView] PATCH failed:', err);
});
}
/**
* Show inline kill confirmation inside the flyout menu.
* Replaces the "Kill Session" item with "Kill? [Yes] [No]".
* No timeout — stays until click-outside closes the menu.
* On error: "Failed" for 2 seconds then reverts.
* @param {HTMLElement} killItem - The "Kill Session" menu item element
*/
function _doKillSessionInline(killItem) {
var sessionName = _flyoutSessionName;
var remoteId = _flyoutRemoteId;
// Replace the kill item with confirmation UI
var confirmHtml =
'
' +
'Kill?' +
'' +
'' +
'
';
killItem.outerHTML = confirmHtml;
// Re-attach handlers on the confirm/cancel buttons
if (!_flyoutMenuEl) return;
var confirmBtn = _flyoutMenuEl.querySelector('[data-action="confirm-kill"]');
var cancelBtn = _flyoutMenuEl.querySelector('[data-action="cancel-kill"]');
if (confirmBtn) {
confirmBtn.addEventListener('click', function(e) {
e.stopPropagation();
_executeKill(sessionName, remoteId);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', function(e) {
e.stopPropagation();
closeFlyoutMenu();
});
}
}
/**
* Execute the kill session API call from the flyout inline confirmation.
* On success: closes flyout, shows toast, refreshes sessions.
* On error: shows "Failed" for 2s in the confirm area, then reverts.
* @param {string} name
* @param {string} remoteId
*/
function _executeKill(name, remoteId) {
var endpoint = remoteId
? '/api/federation/' + encodeURIComponent(remoteId) + '/sessions/' + encodeURIComponent(name)
: '/api/sessions/' + encodeURIComponent(name);
api('DELETE', endpoint)
.then(function() {
closeFlyoutMenu();
showToast('Session \'' + name + '\' killed');
if (_viewingSession === name) {
closeSession();
}
pollSessions();
})
.catch(function(err) {
// Show "Failed" for 2 seconds
var confirmDiv = _flyoutMenuEl && _flyoutMenuEl.querySelector('.flyout-menu__confirm');
if (confirmDiv) {
confirmDiv.innerHTML = 'Failed';
setTimeout(function() {
// Revert to original kill button if menu is still open
if (_flyoutMenuEl && confirmDiv.parentNode) {
confirmDiv.outerHTML =
'';
}
}, 2000);
}
});
}
// ─── Manage View Panel ──────────────────────────────────────────────────────────────────────────
/**
* Open the Manage View panel for the active user view.
* Only available for user views (not "All" or "Hidden").
*/
function openManageViewPanel() {
if (_activeView === 'all' || _activeView === 'hidden') return;
var panel = $('manage-view-panel');
if (!panel) return;
// Update title/name
var nameEl = $('manage-view-name');
if (nameEl) nameEl.textContent = _activeView;
renderManageViewList();
panel.classList.remove('hidden');
// Close on backdrop click
var backdrop = $('manage-view-backdrop');
if (backdrop) {
backdrop.onclick = closeManageViewPanel;
}
// Close button at bottom
var closeBtn = $('manage-view-close');
if (closeBtn) {
closeBtn.onclick = closeManageViewPanel;
}
}
/**
* Close the Manage View panel.
*/
function closeManageViewPanel() {
var panel = $('manage-view-panel');
if (panel) panel.classList.add('hidden');
}
/**
* Render the session list inside the Manage View panel.
* Shows ALL sessions: checked = in this view, unchecked = not in this view.
* 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 renderManageViewList() {
var listEl = $('manage-view-list');
var summaryEl = $('manage-view-summary');
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)
var allSessions = (_currentSessions || []).filter(function(s) {
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 key = s.sessionKey || s.name;
return viewSessions.indexOf(key) === -1 && viewSessions.indexOf(s.name) === -1;
});
// Sort each group: alphabetical, grouped by device
function sortByDeviceAlpha(arr) {
return arr.slice().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 sorted = sortByDeviceAlpha(inView).concat(sortByDeviceAlpha(notInView));
var html = '';
for (var j = 0; j < sorted.length; j++) {
var s = sorted[j];
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 escapedName = escapeHtml(s.name || '');
var deviceName = escapeHtml(_getDeviceDisplayName(s) || '');
html += '';
if (isHidden) {
html += '
Adding will unhide this session
';
}
}
listEl.innerHTML = html;
// Delegated change handler for immediate-commit checkboxes
listEl.onchange = function(e) {
var cb = e.target.closest('.manage-view-item__checkbox');
if (!cb) return;
var sessionKey = cb.dataset.sessionKey;
var isChecked = cb.checked;
var isHiddenSession = cb.dataset.isHidden === '1';
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 (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('Couldn’t save — try again');
if (cb) cb.checked = !isChecked;
console.warn('[renderManageViewList] PATCH failed:', err);
});
};
}
/**
* 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 '';
}
// ─── Notification permission ────────────────────────────────────────────────
/**
* Request browser notification permission on first load.
* - If the Notification API is not available, returns immediately.
* - If already granted, records the state synchronously.
* - If default (not yet asked), calls requestPermission() and stores the result.
* - Otherwise (e.g. denied), stores the current permission value.
*/
function requestNotificationPermission() {
if (typeof Notification === 'undefined') return;
if (Notification.permission === 'granted') {
_notificationPermission = 'granted';
} else if (Notification.permission === 'default') {
Notification.requestPermission().then((permission) => {
_notificationPermission = permission;
});
} else {
_notificationPermission = Notification.permission;
}
}
// ─── Bell transition notifications ─────────────────────────────────────────
/**
* Fire OS notifications for sessions that have newly received a bell event.
* Only fires when the Notification permission is granted AND the browser tab
* is currently hidden (document.hidden === true).
* Uses a per-session tag so the OS deduplicates multiple bells into one
* notification per session.
* @param {object[]} prevSessions - sessions array from the previous poll
* @param {object[]} nextSessions - sessions array from the current poll
*/
function handleBellTransitions(prevSessions, nextSessions) {
const transitions = detectBellTransitions(prevSessions, nextSessions);
for (const name of transitions) {
if (_notificationPermission === 'granted' && document.hidden) {
// eslint-disable-next-line no-new
new Notification('Activity in: ' + name, {
body: 'tmux session needs attention',
tag: 'tmux-bell-' + name,
});
}
}
}
// ─── Heartbeat ──────────────────────────────────────────────────────────────────
/**
* Send a single heartbeat POST to /api/heartbeat.
* Catches errors and logs them as warnings — never throws.
* @returns {Promise}
*/
async function sendHeartbeat() {
try {
// When the browser tab is hidden (user switched tabs or minimized), report
// viewing_session as null. This prevents the server from clearing bells on
// the session — the user isn't actually looking at it, so activity should
// accumulate and show in the favicon badge / tab indicators.
var effectiveSession = (typeof document !== 'undefined' && document.hidden)
? null
: _viewingSession;
const payload = buildHeartbeatPayload(_deviceId, effectiveSession, _viewMode, _lastInteractionAt);
await api('POST', '/api/heartbeat', payload);
} catch (err) {
console.warn('[sendHeartbeat] heartbeat failed:', err);
}
}
/**
* Start the heartbeat loop. Guards against double-start.
* Uses self-scheduling setTimeout so at most one heartbeat is in-flight at a time.
* Calls sendHeartbeat() immediately, then HEARTBEAT_MS after each completion.
*/
function startHeartbeat() {
if (_heartbeatTimer) return;
_heartbeatTimer = true; // sentinel: prevents double-start before first setTimeout fires
async function heartbeatLoop() {
await sendHeartbeat();
_heartbeatTimer = setTimeout(heartbeatLoop, HEARTBEAT_MS);
}
heartbeatLoop();
}
/** Test-only helper: reset heartbeat timer state so tests can exercise startHeartbeat cleanly. */
function _resetHeartbeatTimer() {
if (_heartbeatTimer) clearTimeout(_heartbeatTimer);
_heartbeatTimer = undefined;
}
// ─── Toast notification ─────────────────────────────────────────────────────
/**
* Show a brief toast message.
* Removes the 'hidden' class immediately, then restores it after 3000ms.
* @param {string} msg
*/
function showToast(msg) {
const el = $('toast');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 3000);
}
// ─── Session pill bell ───────────────────────────────────────────────────────
/**
* Update the floating session-pill bell indicator.
* Shows #session-pill-bell if any session other than _viewingSession has unseen bells.
*/
function updatePillBell() {
const el = $('session-pill-bell');
if (!el) return;
const viewingKey = _viewingRemoteId ? (_viewingRemoteId + ':' + _viewingSession) : _viewingSession;
const hasBell = _currentSessions.some(
(s) => (s.sessionKey || s.name) !== viewingKey && s.bell && s.bell.unseen_count > 0,
);
if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden');
}
// ---------------------------------------------------------------------------
// Dynamic favicon — activity dot overlay
// ---------------------------------------------------------------------------
var _originalFavicon = null; // cached original favicon href
var _faviconImage = null; // cached Image object for favicon badge compositing — avoids re-fetching every poll
/**
* Draw the favicon activity badge onto the element.
* Owns the _faviconImage lifecycle: lazily creates it once (caching it in the module-level
* variable) and reuses it on all subsequent calls. This avoids re-fetching favicon-32.png
* on every poll cycle (previously new Image() was created inside updateFaviconBadge every 2s).
* If the image is not yet loaded, registers an onload callback to retry automatically.
*/
function _drawFaviconBadge() {
// Lazy-init: create the Image object once and cache it — subsequent calls reuse it
if (!_faviconImage) {
_faviconImage = new Image();
// No crossOrigin: favicon is same-origin; crossOrigin on same-origin images can
// cause cache misses when the browser has the asset cached without CORS headers.
_faviconImage.src = _originalFavicon;
}
// If image is not yet loaded, wait for it (onload will call us back)
if (!_faviconImage.complete || _faviconImage.naturalWidth === 0) {
_faviconImage.onload = function() { _drawFaviconBadge(); };
return;
}
var link = document.querySelector('link[rel="icon"][sizes="32x32"]') ||
document.querySelector('link[rel="icon"]');
if (!link) return;
var canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
var ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(_faviconImage, 0, 0, 32, 32);
// Activity dot — brand amber (same as bell indicator)
ctx.beginPath();
ctx.arc(24, 8, 7, 0, 2 * Math.PI); // top-right area
ctx.fillStyle = '#F1A640'; // var(--bell-color)
ctx.fill();
ctx.strokeStyle = '#0D1117'; // var(--bg) — border for contrast
ctx.lineWidth = 2;
ctx.stroke();
link.href = canvas.toDataURL('image/png');
}
/**
* Update the favicon with an activity dot if any session has unseen bells.
* Uses a 32x32 canvas to draw the original favicon + a colored circle overlay.
* Restores the original favicon when there are no unseen bells.
* Delegates drawing to _drawFaviconBadge which manages the cached Image object.
*/
function updateFaviconBadge() {
var visible = getVisibleSessions(_currentSessions);
var hasActivity = visible.length > 0 && visible.some(function (s) {
return s.bell && s.bell.unseen_count > 0;
});
var link = document.querySelector('link[rel="icon"][sizes="32x32"]') ||
document.querySelector('link[rel="icon"]');
if (!link) return;
// Cache the original favicon href on first call
if (!_originalFavicon) _originalFavicon = link.href;
if (!hasActivity) {
// Restore original favicon when no activity
if (link.href !== _originalFavicon) link.href = _originalFavicon;
return;
}
_drawFaviconBadge();
}
/**
* Update the page title with an optional activity count prefix and the hostname.
* Format: "(N) hostname - muxplex" when N sessions have unseen bells, otherwise
* "hostname - muxplex". Hostname is device_name from server settings, falling back
* to location.hostname so even unconfigured installs show something useful.
* Call from pollSessions() on every tick, and whenever server settings change.
*/
function updatePageTitle() {
var hostname = (_serverSettings && _serverSettings.device_name) ||
(typeof location !== 'undefined' ? location.hostname : null) ||
'muxplex';
var visible = getVisibleSessions(_currentSessions);
var count = visible.filter(function(s) {
return s.bell && s.bell.unseen_count > 0;
}).length;
var prefix = count > 0 ? '(' + count + ') ' : '';
document.title = prefix + hostname + ' - muxplex';
}
// ─── Session open / close ────────────────────────────────────────────────────
/**
* Open a session in fullscreen view with a zoom transition.
* @param {string} name - session name
* @param {object} [opts]
* @param {boolean} [opts.skipAnimation] - if true, skip the zoom animation (e.g. on page restore)
* @returns {Promise}
*/
async function openSession(name, opts = {}) {
if (!name || !name.trim()) return;
hidePreview();
_viewingSession = name;
_viewingRemoteId = opts.remoteId != null ? opts.remoteId : '';
_viewMode = 'fullscreen';
// Pre-render sidebar with current sessions before first poll tick
initSidebar();
renderSidebar(_currentSessions, name);
// Update expanded header
const nameEl = $('expanded-session-name');
if (nameEl) nameEl.textContent = name;
// Zoom animation: pin tile at current position, then animate to full viewport
// Skipped on restore (skipAnimation:true) — no tile DOM element to zoom from
const tile = opts.skipAnimation ? null : document.querySelector(`[data-session="${name}"]`);
if (tile) {
const rect = tile.getBoundingClientRect();
tile.style.position = 'fixed';
tile.style.top = rect.top + 'px';
tile.style.left = rect.left + 'px';
tile.style.width = rect.width + 'px';
tile.style.height = rect.height + 'px';
tile.style.transition = 'none';
// Force reflow
void tile.offsetWidth;
tile.style.transition = 'all 250ms ease';
tile.style.top = '0';
tile.style.left = '0';
tile.style.width = '100vw';
tile.style.height = '100vh';
}
// Start animation concurrently with /connect POST — resolve when view is ready
var animDone = new Promise(function (resolve) {
var timerId = setTimeout(function () {
var overview = $('view-overview');
var expanded = $('view-expanded');
if (overview) overview.style.display = 'none';
if (expanded) {
expanded.classList.remove('hidden'); // must remove class — !important wins over style.display
expanded.classList.add('view--active'); // makes it display:flex
}
// Re-render sidebar after DOM is visible and dimensions are correct
initSidebar();
renderSidebar(_currentSessions, name);
resolve();
}, opts.skipAnimation ? 0 : 260);
// If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang
if (timerId == null) resolve();
});
// Mobile pill
if (isMobile()) {
const pill = $('session-pill');
if (pill) {
pill.classList.remove('hidden'); // pill starts with hidden class
const pillLabel = $('session-pill-label');
if (pillLabel) pillLabel.textContent = name;
}
updatePillBell();
updateSessionPill(_currentSessions);
}
// Hide FAB during fullscreen session view
const fab = $('new-session-fab');
if (fab) fab.classList.add('hidden');
// Always spawn ttyd for this session — ensures correct session after service restart or page restore
// _deviceId holds the device_id string (was integer remoteId index in old protocol)
var _deviceId = opts.remoteId != null ? opts.remoteId : '';
try {
if (_deviceId !== '') {
// Remote session: route connect POST through same-origin federation proxy
await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name));
} else {
await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect');
}
} catch (err) {
showToast(err.message || 'Connection failed');
return closeSession();
}
// Persist active_remote_id so restoreState() can reopen remote sessions after page refresh
api('PATCH', '/api/state', { active_session: name, active_remote_id: _deviceId || null }).catch(function() {});
// Fire-and-forget bell-clear for remote sessions — acknowledge bells on the remote server
if (_deviceId !== '') {
api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/sessions/' + encodeURIComponent(name) + '/bell/clear').catch(function() {});
}
// Wait for animation to finish (may already be done if /connect was slow)
await animDone;
// Mount terminal NOW — /connect has completed, new ttyd is serving the correct session
if (window._openTerminal) window._openTerminal(name, _deviceId, getDisplaySettings().fontSize);
}
/**
* Close the current session and return to the grid view.
* @returns {Promise}
*/
function closeSession() {
_viewMode = 'grid';
_viewingSession = null;
if (window._closeTerminal) window._closeTerminal();
// Fire-and-forget DELETE — skip for remote sessions (they don't need to know we stopped watching)
if (_viewingRemoteId === '') {
api('DELETE', '/api/sessions/current').catch(function() {});
}
// Clear active_remote_id so a page refresh does not attempt to reopen the remote session
api('PATCH', '/api/state', { active_session: null, active_remote_id: null }).catch(function() {});
_viewingRemoteId = '';
const expanded = $('view-expanded');
const overview = $('view-overview');
if (expanded) {
expanded.classList.add('hidden');
expanded.classList.remove('view--active');
}
if (overview) overview.style.display = ''; // overview uses view--active (no !important), style.display clears fine
// Reapply fit layout after overview becomes visible again
var _closDs = getDisplaySettings();
if ((_closDs.viewMode || 'auto') === 'fit') {
var _closGrid = document.getElementById('session-grid');
if (_closGrid) {
_closGrid.classList.add('session-grid--fit');
applyFitLayout(_closGrid);
}
}
const pill = $('session-pill');
if (pill) pill.classList.add('hidden');
// Restore FAB when returning to overview
const fab = $('new-session-fab');
if (fab) fab.classList.remove('hidden');
return Promise.resolve();
}
/** Test-only helper: set _viewingSession directly. */
function _setViewingSession(name) {
_viewingSession = name;
}
// ─── Server settings ─────────────────────────────────────────────────────────
/**
* Load server settings from GET /api/settings and cache in _serverSettings.
* Always resolves — errors are logged as warnings.
* @returns {Promise