feat: hover preview popover on dashboard tiles
Desktop only (guarded by ontouchstart check). On mouseenter of a session tile, shows a floating popover with the full tmux snapshot (all lines, not the cropped 20-line preview). Positioned right of tile if space, else left. 100ms delay prevents flicker on brush-over. Data from _currentSessions (already in memory, zero API calls). Also calls hidePreview() in openSession() so the popover is cleaned up when switching to fullscreen view.
This commit is contained in:
@@ -125,6 +125,8 @@ let _pollingTimer;
|
||||
let _heartbeatTimer;
|
||||
let _notificationPermission = 'default';
|
||||
let _pollFailCount = 0;
|
||||
let _previewPopover = null;
|
||||
let _previewTimer = null;
|
||||
|
||||
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
||||
function $(id) {
|
||||
@@ -509,6 +511,62 @@ function renderGrid(sessions) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hover preview popover (desktop only — no hover on touch devices)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showPreview(tile) {
|
||||
var name = tile.dataset.session;
|
||||
if (!name || !_currentSessions) return;
|
||||
|
||||
var session = _currentSessions.find(function (s) { return s.name === name; });
|
||||
if (!session || !session.snapshot) return;
|
||||
|
||||
hidePreview();
|
||||
|
||||
var popover = document.createElement('div');
|
||||
popover.className = 'preview-popover';
|
||||
var pre = document.createElement('pre');
|
||||
pre.textContent = session.snapshot;
|
||||
popover.appendChild(pre);
|
||||
document.body.appendChild(popover);
|
||||
|
||||
// Position: right of tile if room, else left
|
||||
var rect = tile.getBoundingClientRect();
|
||||
var popW = popover.offsetWidth;
|
||||
var popH = popover.offsetHeight;
|
||||
var left, top;
|
||||
|
||||
if (rect.right + popW + 12 < window.innerWidth) {
|
||||
left = rect.right + 8;
|
||||
} else {
|
||||
left = rect.left - popW - 8;
|
||||
}
|
||||
|
||||
// Vertically: align top with tile, clamp to viewport
|
||||
top = rect.top;
|
||||
if (top + popH > window.innerHeight - 16) {
|
||||
top = window.innerHeight - popH - 16;
|
||||
}
|
||||
if (top < 8) top = 8;
|
||||
|
||||
popover.style.left = left + 'px';
|
||||
popover.style.top = top + 'px';
|
||||
|
||||
_previewPopover = popover;
|
||||
}
|
||||
|
||||
function hidePreview() {
|
||||
if (_previewTimer) {
|
||||
clearTimeout(_previewTimer);
|
||||
_previewTimer = null;
|
||||
}
|
||||
if (_previewPopover) {
|
||||
_previewPopover.remove();
|
||||
_previewPopover = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notification permission ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -627,6 +685,7 @@ function updatePillBell() {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function openSession(name, opts = {}) {
|
||||
hidePreview();
|
||||
_viewingSession = name;
|
||||
_viewMode = 'fullscreen';
|
||||
|
||||
@@ -1010,6 +1069,22 @@ function bindStaticEventListeners() {
|
||||
document.addEventListener('keydown', handleGlobalKeydown);
|
||||
on($('session-pill'), 'click', openBottomSheet);
|
||||
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
||||
|
||||
// Hover preview — delegated on grid container (tiles are re-rendered each poll)
|
||||
var gridEl = $('session-grid');
|
||||
if (gridEl && !('ontouchstart' in window)) { // desktop only
|
||||
gridEl.addEventListener('mouseenter', function (e) {
|
||||
var tile = e.target.closest('.session-tile');
|
||||
if (!tile) return;
|
||||
_previewTimer = setTimeout(function () { showPreview(tile); }, 100);
|
||||
}, true); // useCapture: true for delegation with mouseenter
|
||||
|
||||
gridEl.addEventListener('mouseleave', function (e) {
|
||||
var tile = e.target.closest('.session-tile');
|
||||
if (!tile) return;
|
||||
hidePreview();
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test-only helpers ────────────────────────────────────────────────────────
|
||||
@@ -1117,6 +1192,9 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
closeBottomSheet,
|
||||
renderSheetList,
|
||||
updateSessionPill,
|
||||
// Hover preview popover
|
||||
showPreview,
|
||||
hidePreview,
|
||||
// Test-only helpers
|
||||
_setCurrentSessions,
|
||||
_setPaletteFilteredSessions,
|
||||
|
||||
@@ -959,6 +959,35 @@ body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Hover preview popover (desktop dashboard)
|
||||
============================================================ */
|
||||
|
||||
.preview-popover {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
min-width: 480px;
|
||||
max-width: 640px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: none; /* don't interfere with mouse leaving tile */
|
||||
}
|
||||
|
||||
.preview-popover pre {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
white-space: pre;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Responsive overlay sidebar at <960px
|
||||
============================================================ */
|
||||
|
||||
@@ -2050,4 +2050,16 @@ test('openSession mounts terminal AFTER connect POST, not inside animation timer
|
||||
'_openTerminal must appear AFTER the /connect POST in the source');
|
||||
});
|
||||
|
||||
// --- Hover preview popover ---
|
||||
|
||||
test('app.js has hover preview popover with desktop-only guard', () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL('../app.js', import.meta.url), 'utf8'
|
||||
);
|
||||
assert.ok(source.includes('preview-popover'), 'must create popover with preview-popover class');
|
||||
assert.ok(source.includes('ontouchstart'), 'must guard against touch devices (desktop only)');
|
||||
assert.ok(source.includes('session.snapshot'), 'must use full snapshot text (not lastLines)');
|
||||
assert.ok(source.includes('hidePreview'), 'must have cleanup on mouseleave');
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -613,3 +613,21 @@ def test_sidebar_item_has_flex_shrink_zero():
|
||||
assert "flex-shrink: 0" in block, (
|
||||
".sidebar-item must have flex-shrink:0 to prevent compression"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Hover preview popover (desktop dashboard)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_preview_popover_css_exists():
|
||||
"""Preview popover must have CSS rules with position: fixed."""
|
||||
css = read_css()
|
||||
assert ".preview-popover" in css, "must have .preview-popover CSS class"
|
||||
popover_idx = css.index(".preview-popover")
|
||||
block_start = css.index("{", popover_idx)
|
||||
block_end = css.index("}", block_start)
|
||||
block = css[block_start:block_end]
|
||||
assert "position: fixed" in block or "position:fixed" in block, (
|
||||
".preview-popover must use position: fixed"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user