diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 4999411..18a9f42 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -657,21 +657,24 @@ async function openSession(name, opts = {}) { tile.style.height = '100vh'; } - // After animation completes: switch views, then mount terminal - setTimeout(() => { - const overview = $('view-overview'); - const expanded = $('view-expanded'); - if (overview) overview.style.display = 'none'; - if (expanded) { - expanded.classList.remove('hidden'); // must remove class — !important wins over style.display - expanded.classList.add('view--active'); // makes it display:flex - } - // Mount terminal AFTER view is visible so FitAddon measures real dimensions - if (window._openTerminal) window._openTerminal(name); - // Re-render sidebar after DOM is visible and dimensions are correct - initSidebar(); - renderSidebar(_currentSessions, name); - }, 260); + // Start animation concurrently with /connect POST — resolve when view is ready + var animDone = new Promise(function (resolve) { + var timerId = setTimeout(function () { + var overview = $('view-overview'); + var expanded = $('view-expanded'); + if (overview) overview.style.display = 'none'; + if (expanded) { + expanded.classList.remove('hidden'); // must remove class — !important wins over style.display + expanded.classList.add('view--active'); // makes it display:flex + } + // Re-render sidebar after DOM is visible and dimensions are correct + initSidebar(); + renderSidebar(_currentSessions, name); + resolve(); + }, 260); + // If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang + if (timerId == null) resolve(); + }); // Mobile pill if (isMobile()) { @@ -685,7 +688,7 @@ async function openSession(name, opts = {}) { updateSessionPill(_currentSessions); } - // Connect to session (before animation completes so ttyd is ready) + // Connect to session (kill old ttyd, spawn new one for this session) try { if (!opts.skipConnect) { await api('POST', `/api/sessions/${name}/connect`); @@ -694,6 +697,12 @@ async function openSession(name, opts = {}) { showToast(err.message || 'Connection failed'); return closeSession(); } + + // Wait for animation to finish (may already be done if /connect was slow) + await animDone; + + // Mount terminal NOW — /connect has completed, new ttyd is serving the correct session + if (window._openTerminal) window._openTerminal(name); } /** diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index dc200d1..6cc1239 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -45,6 +45,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import fs from 'node:fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -2025,4 +2026,28 @@ test('bindSidebarClickAway registers click listener on terminal-container', () = globalThis.document.getElementById = origGetById; }); +test('openSession mounts terminal AFTER connect POST, not inside animation timer', () => { + const source = fs.readFileSync( + new URL('../app.js', import.meta.url), 'utf8' + ); + + // Find the openSession function body + const fnStart = source.indexOf('async function openSession'); + const fnBody = source.substring(fnStart, fnStart + 3000); + + // _openTerminal must NOT appear inside setTimeout + const setTimeoutIdx = fnBody.indexOf('setTimeout'); + const setTimeoutEnd = fnBody.indexOf('}, 260)', setTimeoutIdx); + const setTimeoutBody = fnBody.substring(setTimeoutIdx, setTimeoutEnd); + + assert.ok(!setTimeoutBody.includes('_openTerminal'), + '_openTerminal must NOT be inside the 260ms setTimeout — causes race condition with /connect POST'); + + // _openTerminal must appear AFTER the /connect POST + const connectIdx = fnBody.indexOf('/api/sessions/'); + const openTermIdx = fnBody.indexOf('_openTerminal', connectIdx); + assert.ok(openTermIdx > connectIdx, + '_openTerminal must appear AFTER the /connect POST in the source'); +}); +