From 1dcf1fc2f3d8a0fff87b4afe1ed32446bd849c54 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 08:59:54 -0700 Subject: [PATCH] fix: trim trailing blank lines BEFORE slice, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions with cursor near the top (e.g. a fresh tunnel/ssh session) have content at rows 1-2 and rows 3-40 blank in a 40-row terminal. The previous fix trimmed AFTER slice(-20), but slice(-20) of a 40-line snapshot grabs the last 20 rows — all blank. Trimming after slice then removed everything, leaving an empty
.

Fix: trim trailing blank lines from the full snapshot first, then
slice the last N lines of what remains. This way a session with 2
content lines at the top is reduced to those 2 lines before slicing,
and both appear in the preview.

Fixes both buildTileHTML (grid tiles) and buildSidebarHTML (sidebar
cards). Tests updated to cover the 40-row terminal scenario.
---
 muxplex/frontend/app.js             | 30 +++++++++--------
 muxplex/frontend/tests/test_app.mjs | 52 +++++++++++++++++++++++++++++
 2 files changed, 68 insertions(+), 14 deletions(-)

diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js
index 797ee82..9492e41 100644
--- a/muxplex/frontend/app.js
+++ b/muxplex/frontend/app.js
@@ -526,14 +526,15 @@ 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;
-  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();
+  // Trim trailing blank lines from the FULL snapshot FIRST — sessions with the cursor
+  // near the top (e.g. fresh tunnel/ssh session) have content at rows 1-2 and rows 3-40
+  // blank. slice(-20) would grab the last 20 rows (all blank); trimming after slice
+  // then removes everything → empty preview. Trim first so slice sees only content rows.
+  var allLines = snapshot.split('\n');
+  while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
+    allLines.pop();
   }
-  const lastLines = lines.join('\n');
+  const lastLines = allLines.slice(_lineCount).join('\n');
 
   const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : '';
   return (
@@ -582,15 +583,16 @@ function buildSidebarHTML(session, currentSession) {
   // Timestamp for meta line
   const timeStr = formatTimestamp(session.last_activity_at || null);
 
-  // Last 20 lines of snapshot
+  // Last 20 lines of snapshot — trim trailing blanks from the FULL snapshot FIRST,
+  // then slice. Sessions with the cursor near the top have content at rows 1-2 and
+  // rows 3-40 blank; slice(-20) would return only blank rows, then trim-after-slice
+  // removes everything → empty preview. Trim first to keep meaningful content.
   const snapshot = session.snapshot || '';
-  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();
+  var allLines = snapshot.split('\n');
+  while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
+    allLines.pop();
   }
-  const lastLines = sidebarLines.join('\n');
+  const lastLines = allLines.slice(-20).join('\n');
 
   return (
     `
` + diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 9561d1a..1459b0c 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -4354,3 +4354,55 @@ test('buildSidebarHTML renders empty pre when snapshot is entirely blank lines', assert.ok(preMatch, '
 element must exist');
   assert.strictEqual(preMatch[1], '', 'sidebar pre should be empty when snapshot has only blank lines');
 });
+
+// --- Trim BEFORE slice: 40-row terminal with content at top ---
+// The real bug: a 40-row terminal with content at rows 1-2 and rows 3-40 blank.
+// slice(-20) grabs the LAST 20 rows → all blank. Then trim-after-slice removes
+// everything → empty pre. Fix: trim trailing blanks from the full snapshot FIRST,
+// then slice the last 20 of what remains.
+
+test('buildTileHTML shows content from top of 40-row terminal (trim BEFORE slice)', () => {
+  // 40 lines: 2 content lines + 38 trailing blank lines (realistic 40-row terminal,
+  // cursor near top — e.g. a fresh ssh tunnel session)
+  const snapshot = 'TUNNEL_CONTENT_LINE\nstatus: connected\n' + '\n'.repeat(38);
+  const session = { name: 'tunnel', snapshot };
+  const html = app.buildTileHTML(session, 0, false);
+  assert.ok(
+    html.includes('TUNNEL_CONTENT_LINE'),
+    'tile must show content from top of terminal even with 38 trailing blank lines — trim full snapshot BEFORE slice(-20)',
+  );
+});
+
+test('buildSidebarHTML shows content from top of 40-row terminal (trim BEFORE slice)', () => {
+  const snapshot = 'TUNNEL_CONTENT_LINE\nstatus: connected\n' + '\n'.repeat(38);
+  const session = { name: 'tunnel', snapshot, bell: { unseen_count: 0 } };
+  const html = app.buildSidebarHTML(session, '');
+  assert.ok(
+    html.includes('TUNNEL_CONTENT_LINE'),
+    'sidebar must show content from top of terminal even with 38 trailing blank lines — trim full snapshot BEFORE slice(-20)',
+  );
+});
+
+test('buildTileHTML trim happens BEFORE slice in source (structural order check)', () => {
+  const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+  const fnStart = source.indexOf('function buildTileHTML');
+  const fnBody = source.substring(fnStart, fnStart + 2000);
+  const trimIdx = fnBody.indexOf('.pop()');
+  const sliceIdx = fnBody.indexOf('.slice(');
+  assert.ok(
+    trimIdx < sliceIdx,
+    'trailing blank trim (.pop()) must appear BEFORE .slice() in buildTileHTML — trim the full snapshot first, then slice the last N lines',
+  );
+});
+
+test('buildSidebarHTML trim happens BEFORE slice in source (structural order check)', () => {
+  const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
+  const fnStart = source.indexOf('function buildSidebarHTML');
+  const fnBody = source.substring(fnStart, fnStart + 2000);
+  const trimIdx = fnBody.indexOf('.pop()');
+  const sliceIdx = fnBody.indexOf('.slice(');
+  assert.ok(
+    trimIdx < sliceIdx,
+    'trailing blank trim (.pop()) must appear BEFORE .slice() in buildSidebarHTML — trim the full snapshot first, then slice the last 20 lines',
+  );
+});