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:
Brian Krabach
2026-03-30 07:39:18 -07:00
parent b38c87185a
commit e6add00f0d
5 changed files with 195 additions and 15 deletions
+36 -11
View File
@@ -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) => {
+24
View File
@@ -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;
+20
View File
@@ -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
+90 -3
View File
@@ -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'
);
});
+24
View File
@@ -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'
);
});