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:
+74
-35
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user