fix: rewrite hover preview — track by session name, survive innerHTML rebuilds

Previous approach guarded renderGrid/renderSidebar from running while
preview active, but catch-up renders, stale DOM references, and timer
races caused flicker, wrong positioning, and stale content.

New approach: track _previewSessionName (string, not DOM node). Renders
run normally. After each innerHTML rebuild, liftHoveredTile() re-applies
z-index to the new DOM element. repositionPreview() re-queries DOM each
call. Timer always cleared when entering a new tile. No render guards.
This commit is contained in:
Brian Krabach
2026-03-29 17:40:54 -07:00
parent 06d1e02ce1
commit 5906de9e67
2 changed files with 94 additions and 48 deletions
+76 -37
View File
@@ -128,7 +128,7 @@ let _pollFailCount = 0;
let _previewPopover = null; let _previewPopover = null;
let _previewTimer = null; let _previewTimer = null;
var _previewDimmer = null; var _previewDimmer = null;
var _previewEl = null; var _previewSessionName = null; // track by NAME, not DOM element
// ─── DOM helpers ────────────────────────────────────────────────────────────── // ─── DOM helpers ──────────────────────────────────────────────────────────────
function $(id) { function $(id) {
@@ -353,7 +353,6 @@ function buildSidebarHTML(session, currentSession) {
*/ */
function renderSidebar(sessions, currentSession) { function renderSidebar(sessions, currentSession) {
if (_viewMode !== 'fullscreen') return; if (_viewMode !== 'fullscreen') return;
if (_previewPopover) return; // skip re-render while hover preview is active
const list = $('sidebar-list'); const list = $('sidebar-list');
if (!list) return; if (!list) return;
@@ -374,6 +373,9 @@ function renderSidebar(sessions, currentSession) {
}); });
}); });
} }
// Re-apply z-index lift after innerHTML rebuild (preview survives re-renders)
if (_previewSessionName) liftHoveredTile();
} }
const SIDEBAR_KEY = 'muxplex.sidebarOpen'; const SIDEBAR_KEY = 'muxplex.sidebarOpen';
@@ -483,8 +485,6 @@ function bindSidebarClickAway() {
* @param {object[]} sessions * @param {object[]} sessions
*/ */
function renderGrid(sessions) { function renderGrid(sessions) {
if (_previewPopover) return; // skip re-render while hover preview is active
const grid = $('session-grid'); const grid = $('session-grid');
const emptyState = $('empty-state'); const emptyState = $('empty-state');
@@ -514,42 +514,67 @@ function renderGrid(sessions) {
if (_viewMode === 'fullscreen') { if (_viewMode === 'fullscreen') {
updatePillBell(); updatePillBell();
} }
// Re-apply z-index lift after innerHTML rebuild (preview survives re-renders)
if (_previewSessionName) liftHoveredTile();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Hover preview popover (desktop only — no hover on touch devices) // Hover preview popover (desktop only — no hover on touch devices)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function showPreview(el, name) { function showPreview(name) {
// el = the DOM element to position relative to
// name = session name (optional — reads from el.dataset.session if omitted)
if (!name) name = el.dataset.session;
if (!name || !_currentSessions) return; if (!name || !_currentSessions) return;
var session = _currentSessions.find(function (s) { return s.name === name; }); var session = _currentSessions.find(function (s) { return s.name === name; });
if (!session || !session.snapshot) return; if (!session || !session.snapshot) return;
hidePreview(); // If already showing this session's preview, just update content + position
if (_previewPopover && _previewSessionName === name) {
var pre = _previewPopover.querySelector('pre');
if (pre) pre.textContent = session.snapshot;
repositionPreview();
return;
}
// Dim layer — behind popover, above everything else // New preview — clean slate (DOM only, no render trigger)
hidePreviewDOM();
_previewSessionName = name;
// Dimmer layer — behind popover, above everything else
var dimmer = document.createElement('div'); var dimmer = document.createElement('div');
dimmer.className = 'preview-dimmer'; dimmer.className = 'preview-dimmer';
document.body.appendChild(dimmer); document.body.appendChild(dimmer);
_previewDimmer = dimmer; _previewDimmer = dimmer;
// Lift the hovered element above the dimmer // Popover
el.classList.add(el.classList.contains('sidebar-item') ? 'item--previewing' : 'tile--previewing');
_previewEl = el; // track so we can remove the class later
var popover = document.createElement('div'); var popover = document.createElement('div');
popover.className = 'preview-popover'; popover.className = 'preview-popover';
var pre = document.createElement('pre'); var pre = document.createElement('pre');
pre.textContent = session.snapshot; pre.textContent = session.snapshot;
popover.appendChild(pre); popover.appendChild(pre);
document.body.appendChild(popover); document.body.appendChild(popover);
_previewPopover = popover;
// Lift the tile/item above the dimmer + position
liftHoveredTile();
repositionPreview();
// Auto-scroll to bottom — the prompt/cursor area is the valuable part
popover.scrollTop = popover.scrollHeight;
}
function repositionPreview() {
if (!_previewPopover || !_previewSessionName) return;
// Re-query DOM for the current element (may have been rebuilt by a render cycle)
var el = document.querySelector(
'.session-tile[data-session="' + _previewSessionName + '"], ' +
'.sidebar-item[data-session="' + _previewSessionName + '"]'
);
if (!el) return;
// Position: try right of element, then left
var rect = el.getBoundingClientRect(); var rect = el.getBoundingClientRect();
var popover = _previewPopover;
var left; var left;
if (rect.right + 288 < window.innerWidth) { if (rect.right + 288 < window.innerWidth) {
@@ -591,18 +616,26 @@ function showPreview(el, name) {
popover.style.left = left + 'px'; popover.style.left = left + 'px';
popover.style.top = top + 'px'; popover.style.top = top + 'px';
// Auto-scroll to bottom — the prompt/cursor area is the valuable part
popover.scrollTop = popover.scrollHeight;
_previewPopover = popover;
} }
function hidePreview() { function liftHoveredTile() {
if (_previewTimer) { // Remove any existing lift class from all tiles/items
clearTimeout(_previewTimer); document.querySelectorAll('.tile--previewing, .item--previewing').forEach(function (el) {
_previewTimer = null; el.classList.remove('tile--previewing', 'item--previewing');
} });
if (!_previewSessionName) return;
var el = document.querySelector(
'.session-tile[data-session="' + _previewSessionName + '"]'
);
if (el) el.classList.add('tile--previewing');
var item = document.querySelector(
'.sidebar-item[data-session="' + _previewSessionName + '"]'
);
if (item) item.classList.add('item--previewing');
}
// hidePreviewDOM: removes the visual elements only (no render trigger)
function hidePreviewDOM() {
if (_previewPopover) { if (_previewPopover) {
_previewPopover.remove(); _previewPopover.remove();
_previewPopover = null; _previewPopover = null;
@@ -611,15 +644,19 @@ function hidePreview() {
_previewDimmer.remove(); _previewDimmer.remove();
_previewDimmer = null; _previewDimmer = null;
} }
if (_previewEl) { document.querySelectorAll('.tile--previewing, .item--previewing').forEach(function (el) {
_previewEl.classList.remove('tile--previewing', 'item--previewing'); el.classList.remove('tile--previewing', 'item--previewing');
_previewEl = null; });
} }
// Catch-up render after preview is dismissed — apply any data missed while paused
if (_currentSessions) { // hidePreview: full cleanup including timer and session name
renderGrid(_currentSessions); function hidePreview() {
renderSidebar(_currentSessions, _viewingSession); if (_previewTimer) {
clearTimeout(_previewTimer);
_previewTimer = null;
} }
hidePreviewDOM();
_previewSessionName = null;
} }
// ─── Notification permission ──────────────────────────────────────────────── // ─── Notification permission ────────────────────────────────────────────────
@@ -1131,7 +1168,9 @@ function bindStaticEventListeners() {
gridEl.addEventListener('mouseenter', function (e) { gridEl.addEventListener('mouseenter', function (e) {
var tile = e.target.closest('.session-tile'); var tile = e.target.closest('.session-tile');
if (!tile) return; if (!tile) return;
_previewTimer = setTimeout(function () { showPreview(tile); }, 1500); if (_previewTimer) { clearTimeout(_previewTimer); _previewTimer = null; }
var name = tile.dataset.session;
_previewTimer = setTimeout(function () { showPreview(name); }, 1500);
}, true); // useCapture: true for delegation with mouseenter }, true); // useCapture: true for delegation with mouseenter
gridEl.addEventListener('mouseleave', function (e) { gridEl.addEventListener('mouseleave', function (e) {
@@ -1147,9 +1186,9 @@ function bindStaticEventListeners() {
sidebarListEl.addEventListener('mouseenter', function (e) { sidebarListEl.addEventListener('mouseenter', function (e) {
var item = e.target.closest('.sidebar-item'); var item = e.target.closest('.sidebar-item');
if (!item) return; if (!item) return;
if (_previewTimer) { clearTimeout(_previewTimer); _previewTimer = null; }
var name = item.dataset.session; var name = item.dataset.session;
if (!name) return; _previewTimer = setTimeout(function () { showPreview(name); }, 1500);
_previewTimer = setTimeout(function () { showPreview(item, name); }, 1500);
}, true); }, true);
sidebarListEl.addEventListener('mouseleave', function (e) { sidebarListEl.addEventListener('mouseleave', function (e) {
+18 -11
View File
@@ -2091,22 +2091,29 @@ test('hover preview delay is 1500ms (not 350ms)', () => {
assert.ok(!source.includes(', 350)'), 'old 350ms delay must be removed'); assert.ok(!source.includes(', 350)'), 'old 350ms delay must be removed');
}); });
test('renderGrid and renderSidebar skip re-render while preview is active', () => { test('hover preview uses session name tracking instead of DOM element reference', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
new URL('../app.js', import.meta.url), 'utf8' new URL('../app.js', import.meta.url), 'utf8'
); );
assert.ok(source.includes('_previewSessionName'), 'must track by session name, not DOM element');
assert.ok(source.includes('repositionPreview'), 'must have repositionPreview for re-anchoring');
assert.ok(source.includes('liftHoveredTile'), 'must re-lift tile after render cycles');
assert.ok(source.includes('scrollHeight'), 'must auto-scroll popover to bottom');
assert.ok(source.includes('preview-dimmer'), 'must have dim overlay');
assert.ok(source.includes('ontouchstart'), 'must be desktop-only');
});
// Find renderGrid function body test('renderGrid and renderSidebar re-lift hovered tile after innerHTML rebuild', () => {
const gridFnStart = source.indexOf('function renderGrid'); const source = fs.readFileSync(
const gridFnBody = source.substring(gridFnStart, gridFnStart + 500); new URL('../app.js', import.meta.url), 'utf8'
assert.ok(gridFnBody.includes('_previewPopover'), );
'renderGrid must check _previewPopover and skip while active'); // renderGrid should call liftHoveredTile after innerHTML
const gridFn = source.substring(source.indexOf('function renderGrid'), source.indexOf('function renderGrid') + 1500);
assert.ok(gridFn.includes('liftHoveredTile'), 'renderGrid must re-lift tile after render');
// Find renderSidebar function body // renderGrid should NOT have the old _previewPopover guard
const sidebarFnStart = source.indexOf('function renderSidebar'); assert.ok(!gridFn.includes('if (_previewPopover) return'),
const sidebarFnBody = source.substring(sidebarFnStart, sidebarFnStart + 500); 'renderGrid must NOT skip renders while preview active (old approach caused bugs)');
assert.ok(sidebarFnBody.includes('_previewPopover'),
'renderSidebar must check _previewPopover and skip while active');
}); });