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:
Brian Krabach
2026-03-29 14:42:30 -07:00
parent 4b4b914858
commit 6144f39955
4 changed files with 137 additions and 0 deletions
+78
View File
@@ -125,6 +125,8 @@ let _pollingTimer;
let _heartbeatTimer; let _heartbeatTimer;
let _notificationPermission = 'default'; let _notificationPermission = 'default';
let _pollFailCount = 0; let _pollFailCount = 0;
let _previewPopover = null;
let _previewTimer = null;
// ─── DOM helpers ────────────────────────────────────────────────────────────── // ─── DOM helpers ──────────────────────────────────────────────────────────────
function $(id) { 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 ──────────────────────────────────────────────── // ─── Notification permission ────────────────────────────────────────────────
/** /**
@@ -627,6 +685,7 @@ function updatePillBell() {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function openSession(name, opts = {}) { async function openSession(name, opts = {}) {
hidePreview();
_viewingSession = name; _viewingSession = name;
_viewMode = 'fullscreen'; _viewMode = 'fullscreen';
@@ -1010,6 +1069,22 @@ function bindStaticEventListeners() {
document.addEventListener('keydown', handleGlobalKeydown); document.addEventListener('keydown', handleGlobalKeydown);
on($('session-pill'), 'click', openBottomSheet); on($('session-pill'), 'click', openBottomSheet);
on($('sheet-backdrop'), 'click', closeBottomSheet); 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 ──────────────────────────────────────────────────────── // ─── Test-only helpers ────────────────────────────────────────────────────────
@@ -1117,6 +1192,9 @@ if (typeof module !== 'undefined' && module.exports) {
closeBottomSheet, closeBottomSheet,
renderSheetList, renderSheetList,
updateSessionPill, updateSessionPill,
// Hover preview popover
showPreview,
hidePreview,
// Test-only helpers // Test-only helpers
_setCurrentSessions, _setCurrentSessions,
_setPaletteFilteredSessions, _setPaletteFilteredSessions,
+29
View File
@@ -959,6 +959,35 @@ body {
display: block; 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 Responsive overlay sidebar at <960px
============================================================ */ ============================================================ */
+12
View File
@@ -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'); '_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');
});
+18
View File
@@ -613,3 +613,21 @@ def test_sidebar_item_has_flex_shrink_zero():
assert "flex-shrink: 0" in block, ( assert "flex-shrink: 0" in block, (
".sidebar-item must have flex-shrink:0 to prevent compression" ".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"
)