diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index e30bceb..797ee82 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -526,7 +526,14 @@ function buildTileHTML(session, index, mobile) { // Last N lines of snapshot — show more in fit mode so tall tiles fill const snapshot = session.snapshot || ''; var _lineCount = (ds.viewMode === 'fit') ? -80 : -20; - const lastLines = snapshot.split('\n').slice(_lineCount).join('\n'); + var lines = snapshot.split('\n').slice(_lineCount); + // Trim trailing blank lines — sessions with cursor near the top have mostly empty + // lines at the bottom, making the preview appear blank. Removing them lets the + // CSS `position: absolute; bottom: 0` anchor the meaningful content correctly. + while (lines.length > 0 && lines[lines.length - 1].trim() === '') { + lines.pop(); + } + const lastLines = lines.join('\n'); const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : ''; return ( @@ -577,7 +584,13 @@ function buildSidebarHTML(session, currentSession) { // Last 20 lines of snapshot const snapshot = session.snapshot || ''; - const lastLines = snapshot.split('\n').slice(-20).join('\n'); + var sidebarLines = snapshot.split('\n').slice(-20); + // Trim trailing blank lines — sessions with cursor near the top have mostly empty + // lines at the bottom, making the preview appear blank. + while (sidebarLines.length > 0 && sidebarLines[sidebarLines.length - 1].trim() === '') { + sidebarLines.pop(); + } + const lastLines = sidebarLines.join('\n'); return ( `
` + diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index fd12cbd..9561d1a 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -4287,3 +4287,70 @@ test('CSS style.css .tile-time has opacity transition for crossfade', () => { 'style.css must have session-tile:hover .tile-time for crossfade' ); }); + +// --- Trailing blank line trimming in snapshot previews --- + +test('buildTileHTML trims trailing blank lines so pre ends with last content line', () => { + // Simulates a session where cursor is near the top: 2 content lines, then 18 blank lines. + // slice(-20) grabs all 20 lines (2 content + 18 blanks). + // Without trimming, the
 ends with blank lines (last meaningful content is invisible).
+  // With trimming, the 
 ends with the last non-blank line.
+  const contentLines = ['$ tunnel up', '  forwarding 8443...'];
+  const blankLines = new Array(18).fill('');
+  const snapshot = contentLines.concat(blankLines).join('\n');
+
+  const session = { name: 'tunnel', snapshot };
+  const html = app.buildTileHTML(session, 0, false);
+
+  // The pre element must contain the content lines
+  assert.ok(html.includes('tunnel up'), 'tile preview must include content line');
+  assert.ok(html.includes('forwarding 8443'), 'tile preview must include second content line');
+
+  // The pre should NOT end with blank lines (trailing whitespace trimmed)
+  const preMatch = html.match(/
([\s\S]*?)<\/pre>/);
+  assert.ok(preMatch, '
 element must exist in tile HTML');
+  const preContent = preMatch[1];
+  const lastLine = preContent.split('\n').pop();
+  assert.ok(lastLine.trim() !== '',
+    'last line of pre content must not be blank after trimming trailing blank lines');
+});
+
+test('buildSidebarHTML trims trailing blank lines so pre ends with last content line', () => {
+  // Same scenario: 2 content lines at top, 18 blank lines below.
+  const contentLines = ['$ tunnel up', '  forwarding 8443...'];
+  const blankLines = new Array(18).fill('');
+  const snapshot = contentLines.concat(blankLines).join('\n');
+
+  const session = { name: 'tunnel', snapshot, bell: { unseen_count: 0 } };
+  const html = app.buildSidebarHTML(session, '');
+
+  assert.ok(html.includes('tunnel up'), 'sidebar preview must include content line');
+  assert.ok(html.includes('forwarding 8443'), 'sidebar preview must include second content line');
+
+  const preMatch = html.match(/
([\s\S]*?)<\/pre>/);
+  assert.ok(preMatch, '
 element must exist in sidebar HTML');
+  const preContent = preMatch[1];
+  const lastLine = preContent.split('\n').pop();
+  assert.ok(lastLine.trim() !== '',
+    'last line of sidebar pre content must not be blank after trimming trailing blank lines');
+});
+
+test('buildTileHTML renders empty pre when snapshot is entirely blank lines', () => {
+  // Edge case: completely empty session — pre should be empty, not show garbage
+  const snapshot = new Array(30).fill('').join('\n');
+  const session = { name: 'empty-session', snapshot };
+  const html = app.buildTileHTML(session, 0, false);
+  const preMatch = html.match(/
([\s\S]*?)<\/pre>/);
+  assert.ok(preMatch, '
 element must exist');
+  // pre content should be empty string (all blanks trimmed away)
+  assert.strictEqual(preMatch[1], '', 'pre should be empty when snapshot has only blank lines');
+});
+
+test('buildSidebarHTML renders empty pre when snapshot is entirely blank lines', () => {
+  const snapshot = new Array(25).fill('').join('\n');
+  const session = { name: 'empty-session', snapshot, bell: { unseen_count: 0 } };
+  const html = app.buildSidebarHTML(session, '');
+  const preMatch = html.match(/
([\s\S]*?)<\/pre>/);
+  assert.ok(preMatch, '
 element must exist');
+  assert.strictEqual(preMatch[1], '', 'sidebar pre should be empty when snapshot has only blank lines');
+});