mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-12 16:38:39 -04:00
dbeed4b63f
* perf(ui): stop session loading from blocking shell * fix(startup): open routes on their own data, retire the loader for good Follow-up to review on #5927. - Route openers are now classified by the data they actually read. Only /email touches the hydrated session list (its new-chat path falls back to the most recent session's model when no default chat is set), so every other route opens as soon as module wiring completes instead of queueing behind /api/sessions. This is the deferred-route half of #5926, which the first pass left unimplemented. - index.html's 5s fallback removes the loader node again. Leaving it in the DOM indefinitely kept _shouldPreserveStartupComposer true forever on a hung /api/sessions, so the composer stopped clearing on session switch. - A missing session module settles hydration instead of leaving the sidebar on "Loading chats…" and dropping the user's route on the floor. - Startup sequencing moved to static/js/startupShell.js so it can be run by tests. The source-text assertions in test_startup_shell_session_loading.py are replaced by node-driven behavioural tests, per tests/TESTING_STANDARD.md. - Reverted the unrequested loader a11y rework, removed the duplicated inert writes (the module stops the wave interval through a callback), and moved the bootstrap row's inline styles into .session-list-bootstrap. * fix: preserve session bootstrap failure state --------- Co-authored-by: Léo <leograndcontact@gmail.com>
154 lines
5.7 KiB
JavaScript
154 lines
5.7 KiB
JavaScript
// Odysseus UI — startup shell sequencing
|
|
// ES6 module — no application dependencies, DOM only.
|
|
//
|
|
// Revealing the application shell, retiring the boot loader, settling the
|
|
// sidebar's own loading state, and firing a deferred URL route are separate
|
|
// startup concerns that used to sit inline in app.js behind a single promise.
|
|
// They live here so each step has one owner and so the whole contract can be
|
|
// exercised directly (tests/test_startup_shell_js.py) without booting the app.
|
|
|
|
const LOADER_ID = 'app-loader';
|
|
const SESSION_BOOTSTRAP_ROW_ID = 'session-list-loading';
|
|
|
|
// Route openers that read the hydrated session list. Everything else only
|
|
// needs module wiring and must not wait on /api/sessions. `/email` spawns a
|
|
// fresh chat, and that path falls back to the most recent session's model
|
|
// (_createDirectChatFromPreferredModel in app.js) when there is no default
|
|
// chat configured, so it genuinely needs the list.
|
|
const ROUTES_NEEDING_SESSIONS = new Set(['/email']);
|
|
|
|
let _routeOpener = null;
|
|
let _routeOpenerNeedsSessions = false;
|
|
|
|
function _loader() {
|
|
return document.getElementById(LOADER_ID);
|
|
}
|
|
|
|
/** Run `fn` after the next paint has committed (two animation frames). */
|
|
export function afterNextPaint(fn) {
|
|
requestAnimationFrame(() => requestAnimationFrame(fn));
|
|
}
|
|
|
|
// The loader node stays in the DOM while sessions hydrate — sidebar-layout.js
|
|
// and sessions.js both read its presence as a "still starting up" sentinel —
|
|
// but it must stop covering, announcing, and animating over a usable shell.
|
|
function _makeLoaderInert(loader) {
|
|
if (!loader || loader.dataset.shellRevealed === 'true') return;
|
|
loader.dataset.shellRevealed = 'true';
|
|
loader.setAttribute('aria-hidden', 'true');
|
|
loader.style.pointerEvents = 'none';
|
|
loader.style.opacity = '0';
|
|
// index.html's inline bootstrap animates the wave on a 150ms interval.
|
|
// Nothing of it is visible any more, so stop rendering into it.
|
|
try { window.__odysseusLoaderWaveStop?.(); } catch (_) {}
|
|
}
|
|
|
|
/**
|
|
* Hand the shell to the user once core wiring is done. Deferred by one paint
|
|
* so the first frame lands with the app already laid out.
|
|
*/
|
|
export function revealApplicationShellAfterPaint() {
|
|
const loader = _loader();
|
|
if (!loader || loader.dataset.shellRevealScheduled === 'true') return;
|
|
loader.dataset.shellRevealScheduled = 'true';
|
|
afterNextPaint(() => _makeLoaderInert(_loader()));
|
|
}
|
|
|
|
/** Retire the loader node for good. Safe to call after a reveal. */
|
|
export function removeApplicationLoader() {
|
|
const loader = _loader();
|
|
if (!loader) return;
|
|
_makeLoaderInert(loader);
|
|
setTimeout(() => loader.remove(), 300);
|
|
}
|
|
|
|
/**
|
|
* Turn the sidebar's bootstrap row into a failure row. The write is delayed
|
|
* until the session renderer's frame has committed so a late success cannot
|
|
* leave stale failure text behind.
|
|
*/
|
|
export function markSessionListUnavailableIfStillBootstrapping() {
|
|
afterNextPaint(() => {
|
|
const row = document.getElementById(SESSION_BOOTSTRAP_ROW_ID);
|
|
if (!row) return;
|
|
const status = row.querySelector('[data-session-list-status]') || row;
|
|
status.textContent = 'Chats unavailable';
|
|
});
|
|
}
|
|
|
|
/** True when `path`'s route opener reads the hydrated session list. */
|
|
export function routeNeedsSessionData(path) {
|
|
return ROUTES_NEEDING_SESSIONS.has(path);
|
|
}
|
|
|
|
/**
|
|
* Stash a URL route opener for later. At the point app.js resolves the route,
|
|
* the modules its handlers drive (the rail new-chat handler, the email
|
|
* section header handler, sessionModule) are still being wired further down
|
|
* the same init pass, so the opener cannot run inline.
|
|
*/
|
|
export function deferRouteOpener(path, opener) {
|
|
if (!opener) return;
|
|
_routeOpener = opener;
|
|
_routeOpenerNeedsSessions = routeNeedsSessionData(path);
|
|
}
|
|
|
|
/**
|
|
* Fire the deferred route opener if its data is ready. Called once when
|
|
* wiring completes and again after authoritative session hydration; a route
|
|
* that needs no session data takes the first call, one that does takes the
|
|
* second.
|
|
*
|
|
* @returns {boolean} whether an opener ran.
|
|
*/
|
|
export function runDeferredRouteOpener({ sessionsSettled = false } = {}) {
|
|
if (!_routeOpener) return false;
|
|
if (_routeOpenerNeedsSessions && !sessionsSettled) return false;
|
|
const opener = _routeOpener;
|
|
_routeOpener = null;
|
|
_routeOpenerNeedsSessions = false;
|
|
try { opener(); } catch (e) { console.warn('route opener failed:', e); }
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Drive session hydration and everything that hangs off it settling: the
|
|
* sidebar's failure row, the loader node, and any session-dependent route.
|
|
*
|
|
* @param {(() => Promise<boolean>)|null} loadSessions — resolves true only
|
|
* after the session list was authoritatively loaded and applied. Null means
|
|
* the session module failed to load.
|
|
*/
|
|
export function settleSessionHydration(loadSessions) {
|
|
const settle = (succeeded) => {
|
|
if (!succeeded) {
|
|
markSessionListUnavailableIfStillBootstrapping();
|
|
// A later unrelated caller must not be able to release a stale startup
|
|
// opener against unknown session state.
|
|
_routeOpener = null;
|
|
_routeOpenerNeedsSessions = false;
|
|
}
|
|
removeApplicationLoader();
|
|
if (succeeded) runDeferredRouteOpener({ sessionsSettled: true });
|
|
return succeeded;
|
|
};
|
|
if (!loadSessions) {
|
|
return Promise.resolve(settle(false));
|
|
}
|
|
// Kick the request off synchronously — a microtask hop here would delay the
|
|
// fetch this whole change exists to get off the critical path.
|
|
let pending;
|
|
try {
|
|
pending = loadSessions();
|
|
} catch (e) {
|
|
console.warn('loadSessions error:', e);
|
|
return Promise.resolve(settle(false));
|
|
}
|
|
return Promise.resolve(pending)
|
|
.then(result => settle(result === true))
|
|
.catch(e => {
|
|
console.warn('loadSessions error:', e);
|
|
return settle(false);
|
|
});
|
|
}
|