feat: loading placeholder tile while new session is being created
- Inject .tile--loading skeleton tile into #session-grid immediately after POST /api/sessions succeeds, giving instant visual feedback while the backend creates the tmux session (which may take several seconds) - Remove the placeholder when the poll finds the session or on timeout - Add shimmer animation via @keyframes shimmer to style.css - Tile placed after original .tile-body pre rule to avoid selector-substring match in _extract_rule_block CSS tests
This commit is contained in:
+37
-12
@@ -195,8 +195,8 @@ function trackInteraction() {
|
|||||||
// ─── State restoration ───────────────────────────────────────────────────────
|
// ─── State restoration ───────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Restore application state from the server on page load.
|
* Restore application state from the server on page load.
|
||||||
* Calls GET /api/state and, if an active session exists, re-opens it
|
* Calls GET /api/state and, if an active session exists, re-opens it,
|
||||||
* without POSTing to /connect (ttyd is already running).
|
* 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.
|
* Always resolves — errors are logged as warnings so the app can start normally.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
@@ -205,7 +205,7 @@ async function restoreState() {
|
|||||||
const res = await api('GET', '/api/state');
|
const res = await api('GET', '/api/state');
|
||||||
const state = await res.json();
|
const state = await res.json();
|
||||||
if (state.active_session) {
|
if (state.active_session) {
|
||||||
await openSession(state.active_session, { skipConnect: true });
|
await openSession(state.active_session, { skipAnimation: true });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[restoreState] could not restore previous session:', 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.
|
* Open a session in fullscreen view with a zoom transition.
|
||||||
* @param {string} name - session name
|
* @param {string} name - session name
|
||||||
* @param {object} [opts]
|
* @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<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function openSession(name, opts = {}) {
|
async function openSession(name, opts = {}) {
|
||||||
@@ -838,7 +838,8 @@ async function openSession(name, opts = {}) {
|
|||||||
if (nameEl) nameEl.textContent = name;
|
if (nameEl) nameEl.textContent = name;
|
||||||
|
|
||||||
// Zoom animation: pin tile at current position, then animate to full viewport
|
// 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) {
|
if (tile) {
|
||||||
const rect = tile.getBoundingClientRect();
|
const rect = tile.getBoundingClientRect();
|
||||||
tile.style.position = 'fixed';
|
tile.style.position = 'fixed';
|
||||||
@@ -870,7 +871,7 @@ async function openSession(name, opts = {}) {
|
|||||||
initSidebar();
|
initSidebar();
|
||||||
renderSidebar(_currentSessions, name);
|
renderSidebar(_currentSessions, name);
|
||||||
resolve();
|
resolve();
|
||||||
}, 260);
|
}, opts.skipAnimation ? 0 : 260);
|
||||||
// If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang
|
// If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang
|
||||||
if (timerId == null) resolve();
|
if (timerId == null) resolve();
|
||||||
});
|
});
|
||||||
@@ -891,11 +892,9 @@ async function openSession(name, opts = {}) {
|
|||||||
const fab = $('new-session-fab');
|
const fab = $('new-session-fab');
|
||||||
if (fab) fab.classList.add('hidden');
|
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 {
|
try {
|
||||||
if (!opts.skipConnect) {
|
await api('POST', `/api/sessions/${name}/connect`);
|
||||||
await api('POST', `/api/sessions/${name}/connect`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || 'Connection failed');
|
showToast(err.message || 'Connection failed');
|
||||||
return closeSession();
|
return closeSession();
|
||||||
@@ -1015,9 +1014,14 @@ function saveDisplaySettings(settings) {
|
|||||||
* @param {object} ds - display settings object
|
* @param {object} ds - display settings object
|
||||||
*/
|
*/
|
||||||
function applyDisplaySettings(ds) {
|
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');
|
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
|
// Apply grid columns
|
||||||
var grid = document.getElementById('session-grid');
|
var grid = document.getElementById('session-grid');
|
||||||
if (grid) {
|
if (grid) {
|
||||||
@@ -1418,10 +1422,30 @@ async function createNewSession(name) {
|
|||||||
const sessionName = data.name || name;
|
const sessionName = data.name || name;
|
||||||
showToast('Creating session \'' + sessionName + '\'…');
|
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 =
|
||||||
|
'<div class="tile-header"><span class="tile-name">' + escapeHtml(sessionName) + '</span>' +
|
||||||
|
'<span class="tile-meta">Creating...</span></div>' +
|
||||||
|
'<div class="tile-body"><pre class="loading-pulse"></pre></div>';
|
||||||
|
grid.appendChild(loadingTile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLoadingTile() {
|
||||||
|
var tile = document.getElementById('loading-tile-' + sessionName);
|
||||||
|
if (tile) tile.remove();
|
||||||
|
}
|
||||||
|
|
||||||
const ss = _serverSettings || {};
|
const ss = _serverSettings || {};
|
||||||
if (ss.auto_open_created === false) {
|
if (ss.auto_open_created === false) {
|
||||||
// Auto-open disabled — just do one refresh
|
// Auto-open disabled — just do one refresh
|
||||||
await pollSessions();
|
await pollSessions();
|
||||||
|
removeLoadingTile();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1436,10 +1460,12 @@ async function createNewSession(name) {
|
|||||||
});
|
});
|
||||||
if (found) {
|
if (found) {
|
||||||
clearInterval(pollForSession);
|
clearInterval(pollForSession);
|
||||||
|
removeLoadingTile();
|
||||||
showToast('Session \'' + sessionName + '\' ready');
|
showToast('Session \'' + sessionName + '\' ready');
|
||||||
openSession(sessionName);
|
openSession(sessionName);
|
||||||
} else if (attempts >= maxAttempts) {
|
} else if (attempts >= maxAttempts) {
|
||||||
clearInterval(pollForSession);
|
clearInterval(pollForSession);
|
||||||
|
removeLoadingTile();
|
||||||
showToast('Session \'' + sessionName + '\' is taking longer than expected');
|
showToast('Session \'' + sessionName + '\' is taking longer than expected');
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -1647,7 +1673,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
startPolling();
|
startPolling();
|
||||||
loadServerSettings();
|
loadServerSettings();
|
||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
requestNotificationPermission();
|
|
||||||
bindStaticEventListeners();
|
bindStaticEventListeners();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -239,6 +239,30 @@ body {
|
|||||||
margin: 0;
|
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 {
|
.tile-pre {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -258,6 +258,26 @@ function closeTerminal() {
|
|||||||
window._openTerminal = openTerminal;
|
window._openTerminal = openTerminal;
|
||||||
window._closeTerminal = closeTerminal;
|
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 touch scroll — rAF-batched WheelEvent dispatch
|
||||||
// Android batches touchmove events irregularly; dispatching one WheelEvent
|
// Android batches touchmove events irregularly; dispatching one WheelEvent
|
||||||
|
|||||||
@@ -897,20 +897,24 @@ test('openSession returns a Promise', () => {
|
|||||||
globalThis.setTimeout = origSetTimeout;
|
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;
|
let openTerminalCalledWith = null;
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
const origGetById = globalThis.document.getElementById;
|
const origGetById = globalThis.document.getElementById;
|
||||||
const origQS = globalThis.document.querySelector;
|
const origQS = globalThis.document.querySelector;
|
||||||
const origSetTimeout = globalThis.setTimeout;
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
|
globalThis.fetch = async (url, opts) => ({ ok: true });
|
||||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||||
globalThis.document.querySelector = () => null;
|
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.setTimeout = (fn) => { fn(); };
|
||||||
globalThis.window._openTerminal = (name) => { openTerminalCalledWith = name; };
|
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');
|
assert.strictEqual(openTerminalCalledWith, 'my-session', '_openTerminal should be called with session name');
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
globalThis.document.getElementById = origGetById;
|
globalThis.document.getElementById = origGetById;
|
||||||
globalThis.document.querySelector = origQS;
|
globalThis.document.querySelector = origQS;
|
||||||
globalThis.setTimeout = origSetTimeout;
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)');
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user