From 40ec20f4ba2d5011d77e33052c337433f046408d Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 29 Mar 2026 16:48:52 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20hover=20preview=20=E2=80=94=20bottom-anc?= =?UTF-8?q?hor,=20flexible=20width,=20sidebar=20support,=20longer=20delay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-scroll to bottom (prompt area is the valuable part) - Dynamic width based on available space (fixes 3-column layout) - 350ms delay (prevents brush-over flicker) - Extend hover preview to sidebar items (popover to the right) - Remove fixed min/max-width from CSS, calculate in JS --- muxplex/frontend/app.js | 69 ++++++++++++++++++++++++----- muxplex/frontend/style.css | 2 - muxplex/frontend/tests/test_app.mjs | 12 +++++ 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 8d0f7b7..a9534b6 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -515,8 +515,10 @@ function renderGrid(sessions) { // Hover preview popover (desktop only — no hover on touch devices) // --------------------------------------------------------------------------- -function showPreview(tile) { - var name = tile.dataset.session; +function showPreview(el, 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; var session = _currentSessions.find(function (s) { return s.name === name; }); @@ -531,20 +533,42 @@ function showPreview(tile) { 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; + // Position: try right of element, then left + var rect = el.getBoundingClientRect(); + var left; - if (rect.right + popW + 12 < window.innerWidth) { + if (rect.right + 288 < window.innerWidth) { left = rect.right + 8; + } else if (rect.left > 288) { + left = rect.left - 8; // will be adjusted after width is set } else { - left = rect.left - popW - 8; + // Fallback: center horizontally + left = Math.max(8, (window.innerWidth - 400) / 2); } - // Vertically: align top with tile, clamp to viewport - top = rect.top; + // Calculate available width for the popover + var availW; + if (left >= rect.right) { + availW = window.innerWidth - left - 16; + } else if (left < rect.left) { + availW = rect.left - 16; + } else { + availW = window.innerWidth - 32; + } + // Clamp between 280px (minimum readable) and 640px (comfortable max) + var popWidth = Math.min(Math.max(availW, 280), 640); + popover.style.width = popWidth + 'px'; + + // If positioned to the left, adjust left edge now that we know width + var fallback = Math.max(8, (window.innerWidth - 400) / 2); + if (left < rect.left && left !== fallback) { + left = rect.left - popWidth - 8; + if (left < 8) left = 8; + } + + // Vertically: align top with element, clamp to viewport + var popH = popover.offsetHeight; + var top = rect.top; if (top + popH > window.innerHeight - 16) { top = window.innerHeight - popH - 16; } @@ -553,6 +577,9 @@ function showPreview(tile) { popover.style.left = left + 'px'; popover.style.top = top + 'px'; + // Auto-scroll to bottom — the prompt/cursor area is the valuable part + popover.scrollTop = popover.scrollHeight; + _previewPopover = popover; } @@ -1076,7 +1103,7 @@ function bindStaticEventListeners() { gridEl.addEventListener('mouseenter', function (e) { var tile = e.target.closest('.session-tile'); if (!tile) return; - _previewTimer = setTimeout(function () { showPreview(tile); }, 100); + _previewTimer = setTimeout(function () { showPreview(tile); }, 350); }, true); // useCapture: true for delegation with mouseenter gridEl.addEventListener('mouseleave', function (e) { @@ -1085,6 +1112,24 @@ function bindStaticEventListeners() { hidePreview(); }, true); } + + // Hover preview — delegated on sidebar list (items are re-rendered each poll) + var sidebarListEl = $('sidebar-list'); + if (sidebarListEl && !('ontouchstart' in window)) { // desktop only + sidebarListEl.addEventListener('mouseenter', function (e) { + var item = e.target.closest('.sidebar-item'); + if (!item) return; + var name = item.dataset.session; + if (!name) return; + _previewTimer = setTimeout(function () { showPreview(item, name); }, 350); + }, true); + + sidebarListEl.addEventListener('mouseleave', function (e) { + var item = e.target.closest('.sidebar-item'); + if (!item) return; + hidePreview(); + }, true); + } } // ─── Test-only helpers ──────────────────────────────────────────────────────── diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index a34351e..58b2871 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -970,8 +970,6 @@ body { 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); diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index d6a5917..eeb3fb3 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2062,4 +2062,16 @@ test('app.js has hover preview popover with desktop-only guard', () => { assert.ok(source.includes('hidePreview'), 'must have cleanup on mouseleave'); }); +test('hover preview popover works for both grid tiles and sidebar items', () => { + const source = fs.readFileSync( + new URL('../app.js', import.meta.url), 'utf8' + ); + assert.ok(source.includes('preview-popover'), 'must create popover'); + assert.ok(source.includes('ontouchstart'), 'desktop-only guard'); + assert.ok(source.includes('session.snapshot'), 'uses full snapshot'); + assert.ok(source.includes('scrollHeight'), 'auto-scrolls to bottom'); + assert.ok(source.includes('sidebar-list') || source.includes('sidebar-item'), + 'must handle sidebar items too'); +}); +