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',
+  );
+});