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:
+36
-11
@@ -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<void>}
|
||||
*/
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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`);
|
||||
}
|
||||
} 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 =
|
||||
'<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 || {};
|
||||
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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user