diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 44fa0ed..771bdbf 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -195,8 +195,8 @@ function trackInteraction() { // ─── State restoration ─────────────────────────────────────────────────────── /** * Restore application state from the server on page load. - * Calls GET /api/state and, if an active session exists, re-opens it - * without POSTing to /connect (ttyd is already running). + * Calls GET /api/state and, if an active session exists, re-opens it, + * skipping only the zoom animation (ttyd is re-spawned to handle service restarts). * Always resolves — errors are logged as warnings so the app can start normally. * @returns {Promise} */ @@ -205,7 +205,7 @@ async function restoreState() { const res = await api('GET', '/api/state'); const state = await res.json(); if (state.active_session) { - await openSession(state.active_session, { skipConnect: true }); + await openSession(state.active_session, { skipAnimation: true }); } } catch (err) { console.warn('[restoreState] could not restore previous session:', err); @@ -821,7 +821,7 @@ function updatePillBell() { * Open a session in fullscreen view with a zoom transition. * @param {string} name - session name * @param {object} [opts] - * @param {boolean} [opts.skipConnect] - if true, skip the /connect POST (ttyd already running) + * @param {boolean} [opts.skipAnimation] - if true, skip the zoom animation (e.g. on page restore) * @returns {Promise} */ async function openSession(name, opts = {}) { @@ -838,7 +838,8 @@ async function openSession(name, opts = {}) { if (nameEl) nameEl.textContent = name; // Zoom animation: pin tile at current position, then animate to full viewport - const tile = document.querySelector(`[data-session="${name}"]`); + // Skipped on restore (skipAnimation:true) — no tile DOM element to zoom from + const tile = opts.skipAnimation ? null : document.querySelector(`[data-session="${name}"]`); if (tile) { const rect = tile.getBoundingClientRect(); tile.style.position = 'fixed'; @@ -870,7 +871,7 @@ async function openSession(name, opts = {}) { initSidebar(); renderSidebar(_currentSessions, name); resolve(); - }, 260); + }, opts.skipAnimation ? 0 : 260); // If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang if (timerId == null) resolve(); }); @@ -891,11 +892,9 @@ async function openSession(name, opts = {}) { const fab = $('new-session-fab'); if (fab) fab.classList.add('hidden'); - // Connect to session (kill old ttyd, spawn new one for this session) + // Always spawn ttyd for this session — ensures correct session after service restart or page restore try { - if (!opts.skipConnect) { - await api('POST', `/api/sessions/${name}/connect`); - } + await api('POST', `/api/sessions/${name}/connect`); } catch (err) { showToast(err.message || 'Connection failed'); return closeSession(); @@ -1015,9 +1014,14 @@ function saveDisplaySettings(settings) { * @param {object} ds - display settings object */ function applyDisplaySettings(ds) { - // Apply font size as CSS custom property + // Apply font size as CSS custom property (tile previews) document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px'); + // Apply font size to the live xterm.js terminal without reconnecting + if (window._setTerminalFontSize) { + window._setTerminalFontSize(ds.fontSize); + } + // Apply grid columns var grid = document.getElementById('session-grid'); if (grid) { @@ -1418,10 +1422,30 @@ async function createNewSession(name) { const sessionName = data.name || name; showToast('Creating session \'' + sessionName + '\'…'); + // Inject a loading placeholder tile so the user sees feedback immediately + var loadingTile = null; + var grid = document.getElementById('session-grid'); + if (grid) { + loadingTile = document.createElement('div'); + loadingTile.className = 'session-tile tile--loading'; + loadingTile.id = 'loading-tile-' + sessionName; + loadingTile.innerHTML = + '
' + escapeHtml(sessionName) + '' + + 'Creating...
' + + '
'; + grid.appendChild(loadingTile); + } + + function removeLoadingTile() { + var tile = document.getElementById('loading-tile-' + sessionName); + if (tile) tile.remove(); + } + const ss = _serverSettings || {}; if (ss.auto_open_created === false) { // Auto-open disabled — just do one refresh await pollSessions(); + removeLoadingTile(); return; } @@ -1436,10 +1460,12 @@ async function createNewSession(name) { }); if (found) { clearInterval(pollForSession); + removeLoadingTile(); showToast('Session \'' + sessionName + '\' ready'); openSession(sessionName); } else if (attempts >= maxAttempts) { clearInterval(pollForSession); + removeLoadingTile(); showToast('Session \'' + sessionName + '\' is taking longer than expected'); } }, 2000); @@ -1647,7 +1673,6 @@ document.addEventListener('DOMContentLoaded', () => { startPolling(); loadServerSettings(); startHeartbeat(); - requestNotificationPermission(); bindStaticEventListeners(); }) .catch((err) => { diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index c240f09..69666ef 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -239,6 +239,30 @@ body { margin: 0; } +/* Loading placeholder tile — shown while a new session is being created */ +.tile--loading { + opacity: 0.6; + pointer-events: none; +} + +.tile--loading .tile-body pre { + background: repeating-linear-gradient( + 90deg, + var(--border) 0px, + var(--bg-surface) 30px, + var(--border) 60px + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + min-height: 60px; + border-radius: 2px; +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + .tile-pre { position: absolute; inset: 0; diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 78d2159..c328fbb 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -258,6 +258,26 @@ function closeTerminal() { window._openTerminal = openTerminal; window._closeTerminal = closeTerminal; +// --------------------------------------------------------------------------- +// setTerminalFontSize — live font-size update without reconnecting +// --------------------------------------------------------------------------- + +/** + * Update the terminal font size at runtime without reconnecting. + * Modifies _term.options.fontSize and refits the terminal to recalculate dimensions. + * No-op when no terminal is open. + * @param {number} size - font size in pixels + */ +function setTerminalFontSize(size) { + if (!_term) return; + _term.options.fontSize = size; + if (_fitAddon) { + try { _fitAddon.fit(); } catch (_) {} + } +} + +window._setTerminalFontSize = setTerminalFontSize; + // --------------------------------------------------------------------------- // Android touch scroll — rAF-batched WheelEvent dispatch // Android batches touchmove events irregularly; dispatching one WheelEvent diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index de0d8a2..f0dc75f 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -897,20 +897,24 @@ test('openSession returns a Promise', () => { globalThis.setTimeout = origSetTimeout; }); -test('openSession with skipConnect calls window._openTerminal inside setTimeout callback', async () => { +test('openSession with skipAnimation calls window._openTerminal after connect POST', async () => { + // After the fix: skipAnimation only skips the zoom animation, connect always fires. let openTerminalCalledWith = null; + const origFetch = globalThis.fetch; const origGetById = globalThis.document.getElementById; const origQS = globalThis.document.querySelector; const origSetTimeout = globalThis.setTimeout; + globalThis.fetch = async (url, opts) => ({ ok: true }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.querySelector = () => null; - // Use a synchronous mock so setTimeout callbacks run immediately — _openTerminal is now called inside setTimeout + // Use a synchronous mock so setTimeout callbacks run immediately globalThis.setTimeout = (fn) => { fn(); }; globalThis.window._openTerminal = (name) => { openTerminalCalledWith = name; }; - await app.openSession('my-session', { skipConnect: true }); + await app.openSession('my-session', { skipAnimation: true }); assert.strictEqual(openTerminalCalledWith, 'my-session', '_openTerminal should be called with session name'); + globalThis.fetch = origFetch; globalThis.document.getElementById = origGetById; globalThis.document.querySelector = origQS; globalThis.setTimeout = origSetTimeout; @@ -1952,4 +1956,87 @@ test('createNewSession polls for session before auto-opening (not immediate setT ); }); +// --- Issue 1: Loading placeholder tile --- + +test('createNewSession injects tile--loading placeholder after POST succeeds', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const start = source.indexOf('async function createNewSession('); + assert.ok(start !== -1, 'createNewSession must exist'); + const snippet = source.slice(start, start + 2500); + assert.ok(snippet.includes('tile--loading'), 'createNewSession must inject tile--loading placeholder class'); + assert.ok(snippet.includes('loading-tile-'), 'createNewSession must use loading-tile- id prefix for the placeholder'); +}); + +test('createNewSession removes loading placeholder when session is found', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const start = source.indexOf('async function createNewSession('); + const snippet = source.slice(start, start + 2500); + assert.ok( + snippet.includes('loadingTile') && snippet.includes('.remove()'), + 'createNewSession must remove the loading tile (loadingTile.remove()) when session is found' + ); +}); + +test('CSS style.css has tile--loading and shimmer animation', () => { + const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + assert.ok(source.includes('tile--loading'), 'style.css must have .tile--loading rule'); + assert.ok(source.includes('shimmer'), 'style.css must have shimmer animation'); +}); + +// --- Issue 2: Always call connect on restore --- + +test('openSession always POSTs to connect even when skipConnect option is passed', async () => { + // Before the fix, skipConnect:true skipped the connect POST entirely. + // After the fix, connect is always called; the option is renamed to skipAnimation. + const fetchCalls = []; + const origFetch = globalThis.fetch; + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.querySelector = () => null; + globalThis.setTimeout = () => {}; + globalThis.window._openTerminal = () => {}; + + await app.openSession('work', { skipConnect: true }); + + const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect'); + assert.ok(connectCall, 'skipConnect:true must NOT prevent connect POST — connect always fires after fix'); + assert.strictEqual(connectCall.opts.method, 'POST'); + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +}); + +// --- Issue 3: Notification permission on user click only --- + +test('DOMContentLoaded handler does NOT call requestNotificationPermission at startup', () => { + // Browsers require notification permission to be requested in response to a user gesture. + // Auto-calling at startup is silently blocked, leaving the permission in a broken state. + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const domContentLoadedIdx = source.indexOf("document.addEventListener('DOMContentLoaded'"); + assert.ok(domContentLoadedIdx !== -1, 'DOMContentLoaded handler must exist'); + // Extract the DOMContentLoaded handler body (next ~600 chars covers the entire handler) + const handlerBody = source.substring(domContentLoadedIdx, domContentLoadedIdx + 600); + assert.ok( + !handlerBody.includes('requestNotificationPermission'), + 'requestNotificationPermission must NOT be called automatically in DOMContentLoaded — only on user click' + ); +}); + +// --- Issue 4: Apply font size to live terminal without reconnecting --- + +test('applyDisplaySettings calls window._setTerminalFontSize when available', () => { + const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8'); + const fnStart = source.indexOf('function applyDisplaySettings('); + assert.ok(fnStart !== -1, 'applyDisplaySettings must exist'); + const fnBody = source.substring(fnStart, fnStart + 600); + assert.ok( + fnBody.includes('_setTerminalFontSize'), + 'applyDisplaySettings must call window._setTerminalFontSize to update live terminal font size' + ); +}); + diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index d467737..3bf05a3 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -733,4 +733,28 @@ test('terminal.js Android touch scroll is UA-gated', () => { assert.ok(!source.includes('scrollLines'), 'must NOT use scrollLines (scrolls local buffer not PTY)'); }); +// --- Issue 4: setTerminalFontSize --- + +test('terminal.js exposes window._setTerminalFontSize function', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok( + source.includes('window._setTerminalFontSize'), + 'terminal.js must expose window._setTerminalFontSize for live font size updates' + ); +}); + +test('_setTerminalFontSize sets _term.options.fontSize and calls _fitAddon.fit()', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + // The function body must update _term.options.fontSize + assert.ok( + source.includes('_term.options.fontSize = size'), + '_setTerminalFontSize must assign _term.options.fontSize = size' + ); + // And call _fitAddon.fit() + assert.ok( + source.includes('_fitAddon.fit()'), + '_setTerminalFontSize must call _fitAddon.fit() to reflow the terminal' + ); +}); +