diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js
index e1e5535..d2f99f2 100644
--- a/muxplex/frontend/app.js
+++ b/muxplex/frontend/app.js
@@ -869,13 +869,44 @@ function renderSidebar(sessions, currentSession, currentRemoteId) {
if (!list) return;
const visible = getVisibleSessions(sessions);
+ var ds = getDisplaySettings();
if (visible.length === 0) {
list.innerHTML = '
';
return;
}
- let html = '';
+ // Build a map of existing sidebar-item elements keyed by session key
+ var existingItems = new Map();
+ list.querySelectorAll('sidebar-item').forEach(function(el) {
+ var key = el.session && (el.session.sessionKey || el.session.name);
+ if (key) existingItems.set(key, el);
+ });
+
+ var newKeys = new Set();
+ var fragment = document.createDocumentFragment();
+
+ function _setupSidebarItem(session) {
+ var key = session.sessionKey || session.name;
+ newKeys.add(key);
+ var item = existingItems.get(key);
+ if (!item) {
+ item = document.createElement('sidebar-item');
+ // sidebar-select navigates; sidebar-options opens the tile-options-btn flyout
+ item.addEventListener('sidebar-select', function(e) {
+ openSession(e.detail.name, { remoteId: e.detail.remoteId });
+ });
+ item.addEventListener('sidebar-options', function(e) {
+ openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect);
+ });
+ }
+ item.session = session;
+ item.active = (session.name === currentSession && (session.remoteId ?? '') === (currentRemoteId ?? ''));
+ item.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled);
+ item.showDeviceBadges = ds.showDeviceBadges !== false;
+ item.activityIndicator = ds.activityIndicator || 'both';
+ return item;
+ }
if (_serverSettings && _serverSettings.multi_device_enabled) {
// Group sessions by deviceName when multi_device_enabled
@@ -887,28 +918,28 @@ function renderSidebar(sessions, currentSession, currentRemoteId) {
}
for (const [deviceName, deviceSessions] of groups) {
- html += ``;
- html += deviceSessions.map((session) => buildSidebarHTML(session, currentSession, currentRemoteId)).join('');
+ var header = document.createElement('h4');
+ header.className = 'sidebar-device-header';
+ header.textContent = deviceName;
+ fragment.appendChild(header);
+ for (var j = 0; j < deviceSessions.length; j++) {
+ fragment.appendChild(_setupSidebarItem(deviceSessions[j]));
+ }
}
} else {
- // Single source: flat list with no device headers
- html = visible.map((session) => buildSidebarHTML(session, currentSession, currentRemoteId)).join('');
+ for (var i = 0; i < visible.length; i++) {
+ fragment.appendChild(_setupSidebarItem(visible[i]));
+ }
}
- 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 (e.target.closest && e.target.closest('.tile-options-btn')) return;
- if (name !== currentSession || remoteId !== (currentRemoteId ?? '')) openSession(name, { remoteId });
- });
- });
- }
+ // Remove stale items
+ existingItems.forEach(function(item, key) {
+ if (!newKeys.has(key)) item.remove();
+ });
+ // Clear and rebuild
+ list.innerHTML = '';
+ list.appendChild(fragment);
}
const SIDEBAR_NARROW_THRESHOLD = 960;
@@ -997,51 +1028,8 @@ function bindSidebarClickAway() {
* @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];
- var groupSessions = groups[name];
- // Skip device entirely when it has no visible sessions to render.
- // This prevents empty device headers from appearing in the grouped grid
- // (e.g. when all of a device's sessions are hidden in the current view).
- if (groupSessions.length === 0) continue;
- html += '';
- 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.
-}
+// renderGroupedGrid and renderFilterBar removed — logic moved into component.
+// No-op stubs are exported at the bottom of the file for test compatibility.
// ---------------------------------------------------------------------------
// View dropdown — render, open/close, view switching
@@ -1647,10 +1635,6 @@ function switchView(viewName) {
}
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) {
@@ -1662,198 +1646,89 @@ function renderGrid(sessions) {
}
var visible = getVisibleSessions(sessions);
+ var emptyState = $('empty-state');
if (visible.length === 0) {
- // Build status tiles for auth_failed/unreachable sessions even when no regular sessions exist.
- // status:empty sentinels are intentionally ignored — a remote with zero tmux sessions
- // produces no visible tile in any view mode (flat, grouped, or otherwise).
- 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');
+ // Check if there are status sessions (auth_failed / unreachable) the grid will render
+ var hasStatusSessions = (sessions || []).some(function(s) {
+ return s.status === 'auth_failed' || s.status === 'unreachable';
});
- 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');
+ if (hasStatusSessions) 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;
+ if (emptyState) emptyState.classList.add('hidden');
}
- // Diff tiles: reuse existing, add new, remove stale
- var existingTiles = new Map();
- if (grid) {
- grid.querySelectorAll('session-tile').forEach(function(t) {
- existingTiles.set(t.getAttribute('data-session-key') || (t.session && t.session.sessionKey), t);
+ // Set properties on the element — sorting, grouping, and tile
+ // diffing (including alphabetical / localeCompare ordering) are handled inside
+ // the component based on sortOrder and groupMode.
+ var grid = $('session-grid');
+ if (!grid) return;
+ var ds = getDisplaySettings();
+ grid.sessions = sessions;
+ grid.visibleSessions = visible;
+ grid.sortOrder = (_serverSettings && _serverSettings.sort_order) || 'manual';
+ grid.groupMode = _gridViewMode;
+ grid.mobile = isMobile();
+ grid.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled);
+ grid.showDeviceBadges = ds.showDeviceBadges !== false;
+ grid.activityIndicator = ds.activityIndicator || 'both';
+ grid.viewMode = ds.viewMode || 'auto';
+
+ // Bind session-open / session-options event listeners only once
+ if (!grid._eventsbound) {
+ grid._eventsbound = true;
+ grid.addEventListener('session-open', function(e) {
+ openSession(e.detail.name, { remoteId: e.detail.remoteId || undefined });
+ });
+ grid.addEventListener('session-options', function(e) {
+ openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect);
});
}
- var newKeys = new Set();
- var fragment = document.createDocumentFragment();
- var ds = getDisplaySettings();
-
- function _setupTile(s) {
- var key = s.sessionKey || s.name;
- newKeys.add(key);
- var tile = existingTiles.get(key);
- if (!tile) {
- tile = document.createElement('session-tile');
- tile.addEventListener('tile-click', function(e) {
- openSession(e.detail.name, { remoteId: e.detail.remoteId || undefined });
- });
- tile.addEventListener('tile-options', function(e) {
- openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect);
- });
- }
- tile.session = s;
- tile.mobile = mobile;
- tile.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled);
- tile.showDeviceBadges = ds.showDeviceBadges !== false;
- tile.activityIndicator = ds.activityIndicator || 'both';
- tile.viewMode = ds.viewMode || 'auto';
- return tile;
- }
-
- if (_gridViewMode === 'grouped') {
- // Group by deviceName
- var groups = {};
- var groupOrder = [];
- for (var gi = 0; gi < ordered.length; gi++) {
- var dn = ordered[gi].deviceName || 'Unknown';
- if (!groups[dn]) { groups[dn] = []; groupOrder.push(dn); }
- groups[dn].push(ordered[gi]);
- }
- for (var g = 0; g < groupOrder.length; g++) {
- var gname = groupOrder[g];
- var groupSessions = groups[gname];
- if (groupSessions.length === 0) continue;
- var header = document.createElement('h3');
- header.className = 'device-group-header';
- header.textContent = gname;
- fragment.appendChild(header);
- for (var j = 0; j < groupSessions.length; j++) {
- fragment.appendChild(_setupTile(groupSessions[j]));
- }
- }
- } else {
- for (var i = 0; i < ordered.length; i++) {
- fragment.appendChild(_setupTile(ordered[i]));
- }
- }
-
- // Remove stale tiles
- existingTiles.forEach(function(tile, key) {
- if (!newKeys.has(key)) tile.remove();
- });
-
- // Append status tiles for auth_failed and unreachable sessions. status:empty sentinels are
- // intentionally ignored in all view modes — a remote with zero tmux sessions produces no
- // visible tile. auth_failed and unreachable are actionable error states and are always shown.
- 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');
- });
- if (grid) {
- grid.innerHTML = statusTilesHtml;
- grid.prepend(fragment);
- }
-
- // Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped)
- if (filterBar) filterBar.innerHTML = '';
if (_viewMode === 'fullscreen') {
updatePillBell();
}
- // Reapply view mode layout after grid HTML is rebuilt
- var currentDs = getDisplaySettings();
- var currentMode = currentDs.viewMode || 'auto';
+ // Reapply view mode layout after grid properties are set
+ var currentMode = ds.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 : '' });
- }
-}
+// _previewClickHandler removed — preview-click event is handled via component
+// listener in bindStaticEventListeners().
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; });
+ var ds = getDisplaySettings();
+ if (ds.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);
+ var preview = $('hover-preview');
+ if (!preview) return;
+ preview.sessionName = name;
+ preview.snapshot = session.snapshot;
+ preview.open = true;
}
-// hidePreviewDOM: removes the visual elements only (no render trigger)
+// hidePreviewDOM: hides the component
function hidePreviewDOM() {
- document.removeEventListener('click', _previewClickHandler, true);
- if (_previewPopover) {
- _previewPopover.remove();
- _previewPopover = null;
- }
+ var preview = $('hover-preview');
+ if (preview) preview.open = false;
}
// hidePreview: full cleanup including timer and session name
function hidePreview() {
- if (_previewTimer) {
- clearTimeout(_previewTimer);
- _previewTimer = null;
- }
+ if (_previewTimer) { clearTimeout(_previewTimer); _previewTimer = null; }
hidePreviewDOM();
_previewSessionName = null;
}
@@ -3753,7 +3628,10 @@ function handleGlobalKeydown(e) {
function openBottomSheet() {
var sheet = $('bottom-sheet');
if (!sheet) return;
- renderSheetList();
+ sheet.sessions = getVisibleSessions(_currentSessions);
+ sheet.viewingSession = _viewingSession || '';
+ sheet.viewingRemoteId = _viewingRemoteId || '';
+ sheet.open = true;
sheet.classList.remove('hidden');
}
@@ -3763,7 +3641,7 @@ function openBottomSheet() {
*/
function closeBottomSheet() {
var sheet = $('bottom-sheet');
- if (sheet) sheet.classList.add('hidden');
+ if (sheet) { sheet.open = false; sheet.classList.add('hidden'); }
}
/**
@@ -3772,31 +3650,11 @@ function closeBottomSheet() {
* and binds click handlers to switch sessions.
*/
function renderSheetList() {
- var list = $('sheet-list');
- if (!list) return;
- var sorted = sortByPriority(getVisibleSessions(_currentSessions));
- list.innerHTML = sorted.map(function(s) {
- var hasBell = s.bell && s.bell.unseen_count > 0 &&
- (s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
- var isActive = s.name === _viewingSession && (s.remoteId ?? '') === (_viewingRemoteId ?? '');
- var escapedName = escapeHtml(s.name || '');
- var remoteIdAttr = s.remoteId != null ? ' data-remote-id="' + escapeHtml(s.remoteId) + '"' : '';
- return '' +
- '' + escapedName + '' +
- (hasBell ? '' + _icons.bell + '' : '') +
- '' + formatTimestamp(s.bell && s.bell.last_fired_at) + '' +
- '';
- }).join('');
-
- list.querySelectorAll('.sheet-item').forEach(function(item) {
- item.addEventListener('click', function() {
- closeBottomSheet();
- var name = item.dataset.session;
- var remoteId = item.dataset.remoteId || '';
- if (name !== _viewingSession || remoteId !== (_viewingRemoteId ?? '')) openSession(name, { remoteId: remoteId });
- });
- });
+ var sheet = $('bottom-sheet');
+ if (!sheet) return;
+ sheet.sessions = getVisibleSessions(_currentSessions);
+ sheet.viewingSession = _viewingSession || '';
+ sheet.viewingRemoteId = _viewingRemoteId || '';
}
/**
@@ -4240,7 +4098,30 @@ function bindStaticEventListeners() {
document.addEventListener('keydown', handleGlobalKeydown);
var sessionPillEl = $('session-pill');
if (sessionPillEl) sessionPillEl.addEventListener('pill-click', openBottomSheet);
- on($('sheet-backdrop'), 'click', closeBottomSheet);
+ // Bottom sheet — component handles backdrop internally; listen for custom events
+ var bottomSheet = $('bottom-sheet');
+ if (bottomSheet) {
+ bottomSheet.addEventListener('sheet-select', function(e) {
+ closeBottomSheet();
+ openSession(e.detail.name, { remoteId: e.detail.remoteId });
+ });
+ bottomSheet.addEventListener('sheet-close', function() {
+ closeBottomSheet();
+ });
+ }
+
+ // Hover preview — component handles click internally; listen for preview-click
+ var hoverPreview = $('hover-preview');
+ if (hoverPreview) {
+ hoverPreview.addEventListener('preview-click', function(e) {
+ hidePreview();
+ var name = e.detail.name;
+ if (name) {
+ var session = _currentSessions && _currentSessions.find(function(s) { return s.name === name; });
+ openSession(name, { remoteId: (session != null && session.remoteId != null) ? session.remoteId : '' });
+ }
+ });
+ }
// Settings dialog bindings
on($('view-mode-btn'), 'click', cycleViewMode);
@@ -4580,7 +4461,7 @@ if (typeof module !== 'undefined' && module.exports) {
toggleSidebar,
bindSidebarClickAway,
renderGrid,
- renderGroupedGrid,
+ renderGroupedGrid: function renderGroupedGrid() {},
requestNotificationPermission,
handleBellTransitions,
sendHeartbeat,
@@ -4637,7 +4518,7 @@ if (typeof module !== 'undefined' && module.exports) {
openFlyoutMenu,
closeFlyoutMenu,
// Filter bar
- renderFilterBar,
+ renderFilterBar: function renderFilterBar() {},
// View dropdown
renderViewDropdown,
toggleViewDropdown,
diff --git a/muxplex/frontend/components/bottom-sheet-switcher.js b/muxplex/frontend/components/bottom-sheet-switcher.js
new file mode 100644
index 0000000..42f0b36
--- /dev/null
+++ b/muxplex/frontend/components/bottom-sheet-switcher.js
@@ -0,0 +1,81 @@
+import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
+import { unsafeHTML } from '/vendor/lit/lit.min.js';
+import { formatTimestamp, sortByPriority } from './utils.js';
+import { icons } from './icons.js';
+
+/**
+ * - Mobile session-switching overlay.
+ *
+ * Properties:
+ * sessions: Array - session list (will be sorted by priority internally)
+ * viewingSession: String - name of currently viewed session
+ * viewingRemoteId: String - remoteId of currently viewed session
+ * open: Boolean - whether the sheet is visible
+ *
+ * Events:
+ * sheet-select { name, remoteId } - Session selected
+ * sheet-close - Backdrop/handle dismissed
+ */
+export class BottomSheetSwitcher extends LitElement {
+ static properties = {
+ sessions: { type: Array },
+ viewingSession: { type: String, attribute: 'viewing-session' },
+ viewingRemoteId: { type: String, attribute: 'viewing-remote-id' },
+ open: { type: Boolean, reflect: true },
+ };
+
+ // Light DOM — existing CSS applies
+ createRenderRoot() { return this; }
+
+ constructor() {
+ super();
+ this.sessions = [];
+ this.viewingSession = '';
+ this.viewingRemoteId = '';
+ this.open = false;
+ }
+
+ _onBackdropClick() {
+ this.dispatchEvent(new CustomEvent('sheet-close', { bubbles: true, composed: true }));
+ }
+
+ _onItemClick(name, remoteId) {
+ if (name !== this.viewingSession || (remoteId || '') !== (this.viewingRemoteId || '')) {
+ this.dispatchEvent(new CustomEvent('sheet-select', {
+ bubbles: true, composed: true,
+ detail: { name, remoteId: remoteId || '' },
+ }));
+ }
+ this.dispatchEvent(new CustomEvent('sheet-close', { bubbles: true, composed: true }));
+ }
+
+ render() {
+ const sorted = sortByPriority(this.sessions || []);
+ return html`
+
+
+
+
+ ${sorted.map(s => {
+ const hasBell = s.bell && s.bell.unseen_count > 0 &&
+ (s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
+ const isActive = s.name === this.viewingSession &&
+ (s.remoteId ?? '') === (this.viewingRemoteId ?? '');
+ const name = s.name || '';
+ const remoteId = s.remoteId ?? '';
+ return html`
+ - this._onItemClick(name, remoteId)}>
+ ${name}
+ ${hasBell ? html`${unsafeHTML(icons.bell)}` : nothing}
+ ${formatTimestamp(s.bell && s.bell.last_fired_at)}
+
`;
+ })}
+
+
+ `;
+ }
+}
+
+customElements.define('bottom-sheet-switcher', BottomSheetSwitcher);
\ No newline at end of file
diff --git a/muxplex/frontend/components/hover-preview.js b/muxplex/frontend/components/hover-preview.js
new file mode 100644
index 0000000..8383b87
--- /dev/null
+++ b/muxplex/frontend/components/hover-preview.js
@@ -0,0 +1,95 @@
+import { LitElement, html, css } from '/vendor/lit/lit.min.js';
+import { unsafeHTML } from '/vendor/lit/lit.min.js';
+import { ansiToHtml } from './utils.js';
+
+/**
+ * - Full-window snapshot popover for session hover.
+ *
+ * Properties:
+ * sessionName: String - name of the session being previewed
+ * snapshot: String - raw terminal snapshot text (ANSI)
+ * open: Boolean - whether the preview is visible
+ *
+ * Events:
+ * preview-click { name } - Fired when user clicks the preview (navigate to session)
+ * preview-close - Fired when preview should be dismissed
+ */
+export class HoverPreview extends LitElement {
+ static properties = {
+ sessionName: { type: String, attribute: 'session-name' },
+ snapshot: { type: String },
+ open: { type: Boolean, reflect: true },
+ };
+
+ static styles = css`
+ :host { display: none; }
+ :host([open]) { display: block; }
+
+ .preview-popover {
+ position: fixed;
+ inset: 48px 12px 12px 12px;
+ z-index: 300;
+ background: var(--bg-primary, #0d1117);
+ border: 1px solid var(--border-muted, #30363d);
+ border-radius: 8px;
+ overflow: auto;
+ padding: 12px;
+ cursor: pointer;
+ }
+ .preview-popover pre {
+ margin: 0;
+ font-family: inherit;
+ font-size: 12px;
+ line-height: 1.4;
+ white-space: pre;
+ color: var(--fg-default, #c9d1d9);
+ }
+ `;
+
+ constructor() {
+ super();
+ this.sessionName = '';
+ this.snapshot = '';
+ this.open = false;
+ this._boundDocClick = this._onDocumentClick.bind(this);
+ }
+
+ updated(changed) {
+ if (changed.has('open')) {
+ if (this.open) {
+ // Scroll to bottom after render (prompt area)
+ const popover = this.shadowRoot.querySelector('.preview-popover');
+ if (popover) popover.scrollTop = popover.scrollHeight;
+ // Close on any click outside (capture phase, next tick)
+ setTimeout(() => document.addEventListener('click', this._boundDocClick, true), 0);
+ } else {
+ document.removeEventListener('click', this._boundDocClick, true);
+ }
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ document.removeEventListener('click', this._boundDocClick, true);
+ }
+
+ _onDocumentClick(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent('preview-click', {
+ bubbles: true, composed: true,
+ detail: { name: this.sessionName },
+ }));
+ }
+
+ render() {
+ if (!this.open) return html``;
+ return html`
+
+
${unsafeHTML(ansiToHtml(this.snapshot || ''))}
+
+ `;
+ }
+}
+
+customElements.define('hover-preview', HoverPreview);
\ No newline at end of file
diff --git a/muxplex/frontend/components/session-grid.js b/muxplex/frontend/components/session-grid.js
new file mode 100644
index 0000000..6bc4b27
--- /dev/null
+++ b/muxplex/frontend/components/session-grid.js
@@ -0,0 +1,147 @@
+import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
+import { repeat } from '/vendor/lit/lit.min.js';
+import { unsafeHTML } from '/vendor/lit/lit.min.js';
+import { sortByPriority, escapeHtml } from './utils.js';
+import './session-tile.js';
+
+/**
+ * - Dashboard grid of session tiles.
+ *
+ * Owns sorting, grouping, status tiles, and the keyed repeat loop.
+ * Replaces the renderGrid() function's innerHTML + diff-patch approach.
+ *
+ * Properties:
+ * sessions: Array - raw session list (includes status sentinels)
+ * visibleSessions: Array - already-filtered visible sessions
+ * sortOrder: String - 'alphabetical' | 'recent' | 'manual'
+ * groupMode: String - 'flat' | 'grouped'
+ * mobile: Boolean - mobile layout
+ * multiDevice: Boolean - multi-device enabled
+ * showDeviceBadges: Boolean
+ * activityIndicator: String
+ * viewMode: String - 'auto' | 'fit'
+ *
+ * Events:
+ * session-open { name, remoteId }
+ * session-options { name, remoteId, sessionKey, rect }
+ */
+export class SessionGrid extends LitElement {
+ static properties = {
+ sessions: { type: Array },
+ visibleSessions: { type: Array },
+ sortOrder: { type: String, attribute: 'sort-order' },
+ groupMode: { type: String, attribute: 'group-mode' },
+ mobile: { type: Boolean },
+ multiDevice: { type: Boolean, attribute: 'multi-device' },
+ showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' },
+ activityIndicator: { type: String, attribute: 'activity-indicator' },
+ viewMode: { type: String, attribute: 'view-mode' },
+ };
+
+ // Light DOM so existing CSS grid styles apply
+ createRenderRoot() { return this; }
+
+ constructor() {
+ super();
+ this.sessions = [];
+ this.visibleSessions = [];
+ this.sortOrder = 'manual';
+ this.groupMode = 'flat';
+ this.mobile = false;
+ this.multiDevice = false;
+ this.showDeviceBadges = true;
+ this.activityIndicator = 'both';
+ this.viewMode = 'auto';
+ }
+
+ get _ordered() {
+ const visible = this.visibleSessions || [];
+ if (this.sortOrder === 'alphabetical') {
+ return visible.slice().sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+ }
+ return this.mobile ? sortByPriority(visible) : visible;
+ }
+
+ get _statusTiles() {
+ return (this.sessions || []).filter(s =>
+ s.status === 'auth_failed' || s.status === 'unreachable'
+ );
+ }
+
+ _onTileClick(e) {
+ this.dispatchEvent(new CustomEvent('session-open', {
+ bubbles: true, composed: true,
+ detail: e.detail,
+ }));
+ }
+
+ _onTileOptions(e) {
+ this.dispatchEvent(new CustomEvent('session-options', {
+ bubbles: true, composed: true,
+ detail: e.detail,
+ }));
+ }
+
+ _renderTile(s) {
+ return html`
+
+ `;
+ }
+
+ _renderStatusTile(s) {
+ const label = s.status === 'auth_failed' ? 'Auth required' : 'Offline';
+ const cls = s.status === 'auth_failed' ? 'auth' : 'offline';
+ const name = escapeHtml(s.deviceName || 'Unknown');
+ return html`
+
+
+
+ `;
+ }
+
+ render() {
+ const ordered = this._ordered;
+ const statusTiles = this._statusTiles;
+
+ if (ordered.length === 0 && statusTiles.length === 0) {
+ return nothing;
+ }
+
+ if (this.groupMode === 'grouped') {
+ const groups = new Map();
+ const groupOrder = [];
+ for (const s of ordered) {
+ const dn = s.deviceName || 'Unknown';
+ if (!groups.has(dn)) { groups.set(dn, []); groupOrder.push(dn); }
+ groups.get(dn).push(s);
+ }
+
+ return html`
+ ${groupOrder.map(gname => html`
+
+ ${repeat(groups.get(gname), s => s.sessionKey || s.name, s => this._renderTile(s))}
+ `)}
+ ${statusTiles.map(s => this._renderStatusTile(s))}
+ `;
+ }
+
+ return html`
+ ${repeat(ordered, s => s.sessionKey || s.name, s => this._renderTile(s))}
+ ${statusTiles.map(s => this._renderStatusTile(s))}
+ `;
+ }
+}
+
+customElements.define('session-grid', SessionGrid);
\ No newline at end of file
diff --git a/muxplex/frontend/components/sidebar-item.js b/muxplex/frontend/components/sidebar-item.js
new file mode 100644
index 0000000..8b876ee
--- /dev/null
+++ b/muxplex/frontend/components/sidebar-item.js
@@ -0,0 +1,126 @@
+import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
+import { unsafeHTML } from '/vendor/lit/lit.min.js';
+import { escapeHtml, ansiToHtml } from './utils.js';
+import { icons } from './icons.js';
+
+/**
+ * - Session entry in the expanded-view sidebar.
+ *
+ * Properties:
+ * session: Object - session data
+ * active: Boolean - whether this is the currently viewed session
+ * multiDevice: Boolean - multi-device enabled
+ * showDeviceBadges: Boolean - show device name badges
+ * activityIndicator: String - 'none' | 'glow' | 'dot' | 'both'
+ *
+ * Events:
+ * sidebar-select { name, remoteId } - Session clicked
+ * sidebar-options { name, remoteId, sessionKey, rect } - Kebab menu clicked
+ */
+export class SidebarItem extends LitElement {
+ static properties = {
+ session: { type: Object },
+ active: { type: Boolean, reflect: true },
+ multiDevice: { type: Boolean, attribute: 'multi-device' },
+ showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' },
+ activityIndicator: { type: String, attribute: 'activity-indicator' },
+ };
+
+ // Light DOM — existing CSS applies
+ createRenderRoot() { return this; }
+
+ constructor() {
+ super();
+ this.session = {};
+ this.active = false;
+ this.multiDevice = false;
+ this.showDeviceBadges = true;
+ this.activityIndicator = 'both';
+ }
+
+ get _name() { return this.session.name || ''; }
+ get _sessionKey() { return this.session.sessionKey || this._name; }
+ get _remoteId() { return this.session.remoteId ?? null; }
+
+ get _isBell() {
+ const unseen = this.session.bell && this.session.bell.unseen_count;
+ return unseen && unseen > 0;
+ }
+
+ get _classes() {
+ let cls = 'sidebar-item';
+ if (this.active) cls += ' sidebar-item--active';
+ const ind = this.activityIndicator;
+ if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' sidebar-item--bell';
+ if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' sidebar-item--edge-bell';
+ return cls;
+ }
+
+ get _snapshotHtml() {
+ const snapshot = this.session.snapshot || '';
+ const allLines = snapshot.split('\n');
+ while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
+ allLines.pop();
+ }
+ return ansiToHtml(allLines.slice(-20).join('\n'));
+ }
+
+ _onClick(e) {
+ if (e.target.closest('.tile-options-btn')) return;
+ this.dispatchEvent(new CustomEvent('sidebar-select', {
+ bubbles: true, composed: true,
+ detail: { name: this._name, remoteId: this._remoteId },
+ }));
+ }
+
+ _onOptionsClick(e) {
+ e.stopPropagation();
+ const btn = e.currentTarget;
+ this.dispatchEvent(new CustomEvent('sidebar-options', {
+ bubbles: true, composed: true,
+ detail: {
+ name: this._name,
+ remoteId: this._remoteId,
+ sessionKey: this._sessionKey,
+ rect: btn.getBoundingClientRect(),
+ },
+ }));
+ }
+
+ render() {
+ const name = this._name;
+ const remoteIdStr = this._remoteId != null ? String(this._remoteId) : '';
+ const showBadge = this.multiDevice && this.session.deviceName && this.showDeviceBadges;
+
+ return html`
+
+
+
+
${unsafeHTML(this._snapshotHtml)}
+
+
+ `;
+ }
+}
+
+customElements.define('sidebar-item', SidebarItem);
\ No newline at end of file
diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html
index 2db1d4b..18466fc 100644
--- a/muxplex/frontend/index.html
+++ b/muxplex/frontend/index.html
@@ -34,7 +34,7 @@
-
+
No active tmux sessions
@@ -95,13 +95,7 @@
-
+
@@ -126,6 +120,9 @@
+
+
+
@@ -296,6 +293,10 @@
import '/components/connection-status.js';
import '/components/session-pill.js';
import '/components/session-tile.js';
+ import '/components/sidebar-item.js';
+ import '/components/hover-preview.js';
+ import '/components/bottom-sheet-switcher.js';
+ import '/components/session-grid.js';