diff --git a/static/app.js b/static/app.js
index 2f1e8d4bf..514d59d8b 100644
--- a/static/app.js
+++ b/static/app.js
@@ -15,6 +15,12 @@ import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260722emailfastindex1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
+import {
+ revealApplicationShellAfterPaint,
+ runDeferredRouteOpener,
+ deferRouteOpener,
+ settleSessionHydration
+} from './js/startupShell.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
import sessionModule from './js/sessions.js';
@@ -1217,12 +1223,13 @@ function initializeEventListeners() {
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
};
const _opener = _routeOpen[urlPath];
- // Defer the opener — at this point in init, the modules whose handlers
- // we trigger (#rail-new-session click handler, the email-section header
- // click handler in emailInbox, sessionModule's loaded session list) are
- // still being wired up further down in this same function. Stash the
- // opener so it runs from sessionModule.loadSessions().finally() below.
- if (_opener) window._odysseusRouteOpener = _opener;
+ // Defer the opener — at this point in init, the modules whose handlers we
+ // trigger (#rail-new-session click handler, the email-section header click
+ // handler in emailInbox, sessionModule) are still being wired up further
+ // down in this same function. startupShell decides when it can run: as soon
+ // as wiring completes, or — for the routes that read the session list —
+ // once /api/sessions has settled.
+ deferRouteOpener(urlPath, _opener);
// Archive browser tool button
const toolLibraryBtn = el('tool-library-btn');
@@ -4315,6 +4322,10 @@ function startOdysseusApp() {
// Load initial data
presetsModule.loadPresets(uiModule.showError);
+ // Core wiring is complete for this turn — reveal the shell independently of
+ // the session-list request.
+ revealApplicationShellAfterPaint();
+
if (sessionModule) {
sessionModule.initDependencies({
API_BASE: API_BASE,
@@ -4326,21 +4337,19 @@ function startOdysseusApp() {
scrollHistory: uiModule.scrollHistoryInstant
});
- // Load sessions first (critical path) — remove loader when done
- sessionModule.loadSessions()
- .catch(e => console.warn('loadSessions error:', e))
- .finally(() => {
- const loader = document.getElementById('app-loader');
- if (loader) { loader.style.opacity = '0'; setTimeout(() => loader.remove(), 300); }
- // Fire any URL route opener now that sessions + module wiring are
- // ready. Deferred from up top of init for exactly this reason.
- if (window._odysseusRouteOpener) {
- try { window._odysseusRouteOpener(); } catch (_) {}
- window._odysseusRouteOpener = null;
- }
- });
+ // sessionModule is now wired, so every route opener has the modules it
+ // drives. The ones that read no session data open here rather than
+ // queueing behind /api/sessions.
+ runDeferredRouteOpener();
+
+ // The shell is already usable at this point; session hydration is
+ // sidebar-local and settles on its own schedule.
+ settleSessionHydration(() => sessionModule.loadSessions());
} else {
console.error('Session module not loaded!');
+ // Nothing will hydrate. Settle immediately so the sidebar exposes the
+ // failure; session-dependent routes must remain unopened without data.
+ settleSessionHydration(null);
}
const runNonCriticalStartup = (fn, delay = 4000) => {
diff --git a/static/index.html b/static/index.html
index fea4e20ac..c73d9a890 100644
--- a/static/index.html
+++ b/static/index.html
@@ -248,8 +248,8 @@
}, { once: true });
})();
-
-
+
+
@@ -286,7 +286,13 @@
if(!document.getElementById('app-loader')){clearInterval(iv);return}
render();
},150);
- setTimeout(function(){var l=document.getElementById('app-loader');if(l){l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
+ // startupShell.js hides the loader as soon as the shell is wired; it calls
+ // back here to stop the wave because this interval is owned by this script.
+ window.__odysseusLoaderWaveStop=function(){clearInterval(iv)};
+ // Last-resort fallback for a boot that never reaches app.js at all. Must
+ // still REMOVE the node: sessions.js reads its presence as "startup in
+ // progress" and stops clearing the composer while it is around.
+ setTimeout(function(){var l=document.getElementById('app-loader');if(l){clearInterval(iv);l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
})();
@@ -813,7 +819,13 @@
-
+
+
+
+ Loading chats…
+
+
@@ -2531,7 +2543,7 @@
-
+
diff --git a/static/js/sessions.js b/static/js/sessions.js
index edf83c8a4..1fc47a976 100644
--- a/static/js/sessions.js
+++ b/static/js/sessions.js
@@ -1683,8 +1683,21 @@ export async function loadSessions() {
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
}
const res = await fetch(url);
+ if (!res.ok) {
+ let detail = '';
+ try {
+ const payload = await res.json();
+ detail = payload?.detail || payload?.error || '';
+ } catch (_) {}
+ const error = new Error(detail || `Session request failed (HTTP ${res.status})`);
+ error.status = res.status;
+ throw error;
+ }
fetched = await res.json();
}
+ if (!Array.isArray(fetched)) {
+ throw new Error('Session request returned an invalid response');
+ }
sessions = _normalizeSessionsList(fetched);
renderSessionList();
@@ -1807,9 +1820,15 @@ export async function loadSessions() {
_autoCreateInProgress = false;
}
}
+ return true;
} catch (error) {
console.error('Error in loadSessions:', error);
- uiModule.showError('Failed to load sessions: ' + error.message);
+ // app.js's global fetch wrapper owns expired-auth navigation. Avoid
+ // flashing a redundant session error while that 401 redirect is pending.
+ if (error?.status !== 401) {
+ uiModule.showError('Failed to load sessions: ' + error.message);
+ }
+ return false;
}
}
diff --git a/static/js/startupShell.js b/static/js/startupShell.js
new file mode 100644
index 000000000..0879ff517
--- /dev/null
+++ b/static/js/startupShell.js
@@ -0,0 +1,153 @@
+// 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)|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);
+ });
+}
diff --git a/static/style.css b/static/style.css
index 73fdbcd5b..d2116bcbd 100644
--- a/static/style.css
+++ b/static/style.css
@@ -38015,6 +38015,12 @@ button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm:hover .cal-add-label {
outline-offset: 2px;
border-radius: 5px;
}
+/* Bootstrap row shown while the session list hydrates, and on load failure.
+ Reads as a normal list row but is not selectable. */
+.session-list-bootstrap {
+ cursor: default;
+ pointer-events: none;
+}
#email-lib-grid .date-section-header {
padding: 10px 5px 3px;
}
diff --git a/tests/test_startup_session_bootstrap_js.py b/tests/test_startup_session_bootstrap_js.py
new file mode 100644
index 000000000..184a720e3
--- /dev/null
+++ b/tests/test_startup_session_bootstrap_js.py
@@ -0,0 +1,356 @@
+"""Exercise sessions.js and startupShell.js together at the bootstrap seam.
+
+The dependency-heavy session module is copied unchanged except for redirecting
+its static imports to tiny browser stubs. The real loadSessions implementation
+and the real startup-shell coordinator then run together under Node.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+_REPO = Path(__file__).resolve().parent.parent
+_SESSIONS = _REPO / "static" / "js" / "sessions.js"
+_SHELL_URL = (_REPO / "static" / "js" / "startupShell.js").as_uri()
+_HAS_NODE = shutil.which("node") is not None
+
+_IMPORT_REWRITES = {
+ "import Storage from './storage.js';": "import Storage from './storage.mjs';",
+ "import uiModule, { autoResize, styledPrompt } from './ui.js';": (
+ "import uiModule, { autoResize, styledPrompt } from './ui.mjs';"
+ ),
+ "import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';": (
+ "import chatRenderer from './chatRenderer.mjs';"
+ ),
+ "import { providerLogo } from './providers.js';": (
+ "import { providerLogo } from './providers.mjs';"
+ ),
+ "import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';": (
+ "import { initModelPicker, updateModelPicker } from './modelPicker.mjs';"
+ ),
+ "import themeModule from './theme.js';": "import themeModule from './theme.mjs';",
+ "import spinnerModule from './spinner.js';": "import spinnerModule from './spinner.mjs';",
+}
+
+_STUBS = {
+ "storage.mjs": r"""
+const Storage = {
+ get: (key, fallback = null) => localStorage.getItem(key) ?? fallback,
+ set: (key, value) => localStorage.setItem(key, value),
+ remove: (key) => localStorage.removeItem(key),
+ getJSON: (key, fallback) => {
+ try { return JSON.parse(localStorage.getItem(key) ?? JSON.stringify(fallback)); }
+ catch (_) { return fallback; }
+ },
+ setJSON: (key, value) => localStorage.setItem(key, JSON.stringify(value)),
+};
+export default Storage;
+""",
+ "ui.mjs": r"""
+export const autoResize = () => {};
+export const styledPrompt = async () => null;
+const ui = {
+ el: (id) => document.getElementById(id),
+ showError: (message) => globalThis.__sessionErrors.push(String(message)),
+ showToast: () => {},
+ styledConfirm: async () => true,
+};
+export default ui;
+""",
+ "chatRenderer.mjs": (
+ "export default { addMessage: () => null, hideWelcomeScreen: () => {} };\n"
+ ),
+ "providers.mjs": "export const providerLogo = () => '';\n",
+ "modelPicker.mjs": (
+ "export const initModelPicker = () => {};\n"
+ "export const updateModelPicker = () => {};\n"
+ ),
+ "theme.mjs": "export default {};\n",
+ "spinner.mjs": "export default {};\n",
+}
+
+_HARNESS = r"""
+const SESSIONS_URL = 'SESSIONS_PATH';
+const SHELL_URL = 'SHELL_PATH';
+
+function makeStore() {
+ const values = new Map();
+ return {
+ getItem(key) { return values.has(key) ? values.get(key) : null; },
+ setItem(key, value) { values.set(key, String(value)); },
+ removeItem(key) { values.delete(key); },
+ };
+}
+
+function makeClassList() {
+ const values = new Set();
+ return {
+ add(...names) { names.forEach(name => values.add(name)); },
+ remove(...names) { names.forEach(name => values.delete(name)); },
+ contains(name) { return values.has(name); },
+ toggle(name, force) {
+ const enabled = force === undefined ? !values.has(name) : !!force;
+ if (enabled) values.add(name); else values.delete(name);
+ return enabled;
+ },
+ };
+}
+
+function makeWorld() {
+ const byId = new Map();
+ const frames = [];
+ const cancelledFrames = new Set();
+ const timers = [];
+ let nextFrame = 1;
+ let historyWrites = 0;
+
+ function makeElement(id = '') {
+ let html = '';
+ const element = {
+ id,
+ dataset: {},
+ style: {},
+ classList: makeClassList(),
+ children: [],
+ status: null,
+ value: '',
+ disabled: false,
+ removed: false,
+ addEventListener() {},
+ removeEventListener() {},
+ setAttribute(name, value) { this[name] = value; },
+ getAttribute(name) { return this[name] ?? null; },
+ appendChild(child) { this.children.push(child); return child; },
+ insertBefore(child) { this.children.unshift(child); return child; },
+ contains() { return false; },
+ closest() { return null; },
+ querySelector(selector) {
+ if (selector === '[data-session-list-status]') return this.status;
+ return null;
+ },
+ querySelectorAll() { return []; },
+ focus() { document.activeElement = this; },
+ remove() { this.removed = true; if (this.id) byId.delete(this.id); },
+ };
+ Object.defineProperty(element, 'innerHTML', {
+ get() { return html; },
+ set(value) {
+ html = String(value);
+ if (id === 'session-list' && html === '') {
+ const row = byId.get('session-list-loading');
+ if (row) row.remove();
+ }
+ },
+ });
+ return element;
+ }
+
+ const document = {
+ activeElement: null,
+ getElementById: (id) => byId.get(id) || null,
+ querySelector: () => null,
+ querySelectorAll: () => [],
+ createElement: (tag) => makeElement(tag),
+ createDocumentFragment: () => makeElement('fragment'),
+ addEventListener() {},
+ };
+ globalThis.document = document;
+ globalThis.localStorage = makeStore();
+ globalThis.sessionStorage = makeStore();
+ Object.defineProperty(globalThis, 'navigator', {
+ value: { platform: 'Linux' },
+ configurable: true,
+ });
+ globalThis.history = { replaceState() { historyWrites += 1; } };
+ globalThis.window = {
+ document,
+ innerWidth: 1024,
+ innerHeight: 768,
+ location: { origin: 'http://odysseus.test', hash: '', pathname: '/', href: '/' },
+ addEventListener() {},
+ removeEventListener() {},
+ chatModule: {
+ detachCurrentStream() {},
+ showWelcomeScreen() {},
+ },
+ __odysseusDefaultChat: {
+ endpoint_url: 'http://model.test',
+ model: 'test/model',
+ endpoint_id: 'endpoint-1',
+ },
+ };
+ globalThis.location = window.location;
+ globalThis.requestAnimationFrame = (fn) => {
+ const id = nextFrame++;
+ frames.push({ id, fn });
+ return id;
+ };
+ globalThis.cancelAnimationFrame = (id) => cancelledFrames.add(id);
+ globalThis.setTimeout = (fn, ms) => { timers.push({ fn, ms }); return timers.length; };
+ globalThis.clearTimeout = () => {};
+ globalThis.__sessionErrors = [];
+
+ return {
+ add(id, options = {}) {
+ const element = makeElement(id);
+ if (options.statusText !== undefined) {
+ element.status = { textContent: options.statusText };
+ }
+ if (options.value !== undefined) element.value = options.value;
+ byId.set(id, element);
+ return element;
+ },
+ paint(rounds = 1) {
+ for (let i = 0; i < rounds; i += 1) {
+ const due = frames.splice(0, frames.length);
+ for (const frame of due) {
+ if (!cancelledFrames.has(frame.id)) frame.fn();
+ }
+ }
+ },
+ runTimers() {
+ const due = timers.splice(0, timers.length);
+ for (const timer of due) timer.fn();
+ },
+ byId,
+ historyWrites: () => historyWrites,
+ resetHistoryWrites: () => { historyWrites = 0; },
+ };
+}
+
+const world = makeWorld();
+world.add('session-list');
+world.add('sessions-section');
+const message = world.add('message', { value: 'draft before seed' });
+
+const responses = [
+ {
+ ok: true,
+ status: 200,
+ json: async () => [{ id: 'existing', name: 'Existing', folder: 'Assistant', archived: false }],
+ },
+ {
+ ok: false,
+ status: 503,
+ json: async () => ({ detail: 'temporarily unavailable' }),
+ },
+];
+let fetchCount = 0;
+globalThis.fetch = async () => {
+ fetchCount += 1;
+ const response = responses.shift();
+ if (!response) throw new Error('unexpected fetch');
+ return response;
+};
+
+const sessions = await import(SESSIONS_URL + '?bootstrap');
+const shell = await import(SHELL_URL + '?bootstrap');
+
+const seeded = await sessions.loadSessions();
+world.paint(1);
+localStorage.setItem('lastSessionId', 'existing');
+message.value = 'draft must survive';
+document.activeElement = null;
+world.resetHistoryWrites();
+const loader = world.add('app-loader');
+const row = world.add('session-list-loading', { statusText: 'Loading chats…' });
+let opened = 0;
+shell.deferRouteOpener('/email', () => { opened += 1; });
+
+const hydrated = await shell.settleSessionHydration(() => sessions.loadSessions());
+const beforePaint = row.status.textContent;
+world.paint(2);
+world.runTimers();
+const staleRouteRan = shell.runDeferredRouteOpener({ sessionsSettled: true });
+
+const errorsBeforeAuth = __sessionErrors.length;
+globalThis.fetch = async () => {
+ fetchCount += 1;
+ const response = { ok: false, status: 401, json: async () => ({ detail: 'expired' }) };
+ window.location.href = '/login'; // app.js global fetch-wrapper behaviour
+ return response;
+};
+const authResult = await sessions.loadSessions();
+
+console.log(JSON.stringify({
+ seeded,
+ hydrated,
+ beforePaint,
+ afterPaint: row.status.textContent,
+ rowStillPresent: world.byId.has('session-list-loading'),
+ loaderRemoved: loader.removed,
+ opened,
+ staleRouteRan,
+ fetchCount,
+ sessionIds: sessions.getSessions().map(session => session.id),
+ pendingChat: sessions.hasPendingChat(),
+ draft: message.value,
+ lastSessionId: localStorage.getItem('lastSessionId'),
+ historyWrites: world.historyWrites(),
+ errors: __sessionErrors,
+ authResult,
+ authRedirect: window.location.href,
+ authAddedError: __sessionErrors.length !== errorsBeforeAuth,
+}));
+"""
+
+
+@pytest.fixture(scope="module")
+def results(tmp_path_factory):
+ if not _HAS_NODE:
+ pytest.skip("node is not installed")
+
+ module_dir = tmp_path_factory.mktemp("session-bootstrap-js")
+ source = _SESSIONS.read_text(encoding="utf-8")
+ for original, replacement in _IMPORT_REWRITES.items():
+ assert original in source, f"sessions import changed: {original}"
+ source = source.replace(original, replacement, 1)
+ sessions_module = module_dir / "sessions.mjs"
+ sessions_module.write_text(source, encoding="utf-8")
+ for name, stub in _STUBS.items():
+ (module_dir / name).write_text(stub, encoding="utf-8")
+
+ harness = _HARNESS.replace("SESSIONS_PATH", sessions_module.as_uri()).replace(
+ "SHELL_PATH", _SHELL_URL
+ )
+ proc = subprocess.run(
+ ["node", "--input-type=module", "-e", harness],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ assert proc.returncode == 0, f"node harness failed:\n{proc.stderr}"
+ return json.loads(proc.stdout.strip().splitlines()[-1])
+
+
+def test_fulfilled_503_is_not_applied_as_an_empty_session_list(results):
+ assert results["seeded"] is True
+ assert results["hydrated"] is False
+ assert results["sessionIds"] == ["existing"]
+ assert results["pendingChat"] is False, "failure created a default direct chat"
+ assert results["draft"] == "draft must survive"
+ assert results["lastSessionId"] == "existing"
+ assert results["historyWrites"] == 0
+
+
+def test_fulfilled_503_keeps_failure_state_and_route_deferred(results):
+ assert results["beforePaint"] == "Loading chats…"
+ assert results["afterPaint"] == "Chats unavailable"
+ assert results["rowStillPresent"] is True
+ assert results["loaderRemoved"] is True
+ assert results["opened"] == 0
+ assert results["staleRouteRan"] is False
+ assert results["errors"] == [
+ "Failed to load sessions: temporarily unavailable",
+ ]
+
+
+def test_401_keeps_global_auth_redirect_contract(results):
+ assert results["authResult"] is False
+ assert results["authRedirect"] == "/login"
+ assert results["authAddedError"] is False
+ assert results["sessionIds"] == ["existing"]
diff --git a/tests/test_startup_shell_js.py b/tests/test_startup_shell_js.py
new file mode 100644
index 000000000..8dcd6f90b
--- /dev/null
+++ b/tests/test_startup_shell_js.py
@@ -0,0 +1,377 @@
+"""Pin the startup shell contract (static/js/startupShell.js).
+
+Driven through `node --input-type=module` against a stub DOM and a manually
+pumped frame/timer clock, so the real module runs without a browser (same
+approach as test_composer_arrow_up_recall_js.py). Skips when `node` is absent.
+
+Locks in the behaviour #5926 asks for: the shell is revealed one paint after
+wiring and does not wait on /api/sessions; the loader node survives hydration
+as a startup sentinel but is always retired once hydration settles; the sidebar
+owns its own loading/failure row and a successful zero-session render never
+shows a false failure; and a URL route opens only after the data it actually
+needs is authoritatively available.
+"""
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+_REPO = Path(__file__).resolve().parent.parent
+_MODULE = _REPO / "static" / "js" / "startupShell.js"
+_MODULE_URL = _MODULE.as_uri()
+_HAS_NODE = shutil.which("node") is not None
+
+_HARNESS = r"""
+const MODULE_URL = 'MODULE_PATH';
+
+// ── Stub DOM + a clock we pump by hand ────────────────────────────────────
+function makeWorld() {
+ const byId = new Map();
+ const frames = [];
+ const timers = [];
+ const world = {
+ byId,
+ waveStops: 0,
+ addElement(id, { statusText = null } = {}) {
+ const el = {
+ id,
+ dataset: {},
+ style: {},
+ attrs: {},
+ removed: false,
+ status: null,
+ setAttribute(k, v) { this.attrs[k] = v; },
+ getAttribute(k) { return this.attrs[k]; },
+ remove() { this.removed = true; byId.delete(this.id); },
+ querySelector(sel) {
+ return sel === '[data-session-list-status]' ? this.status : null;
+ },
+ };
+ if (statusText !== null) el.status = { textContent: statusText };
+ byId.set(id, el);
+ return el;
+ },
+ // One "paint" = one round of already-queued rAF callbacks. afterNextPaint
+ // chains two, so a committed paint takes two rounds.
+ paint(rounds = 1) {
+ for (let i = 0; i < rounds; i++) {
+ const due = frames.splice(0, frames.length);
+ for (const fn of due) fn();
+ }
+ },
+ runTimers() {
+ const due = timers.splice(0, timers.length);
+ for (const t of due) t.fn();
+ },
+ pendingTimers() { return timers.length; },
+ };
+ globalThis.document = { getElementById: (id) => byId.get(id) || null };
+ globalThis.window = { __odysseusLoaderWaveStop: () => { world.waveStops += 1; } };
+ globalThis.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
+ globalThis.setTimeout = (fn, ms) => { timers.push({ fn, ms }); return timers.length; };
+ return world;
+}
+
+// Fresh module instance per case so deferred-route state cannot leak.
+let _instance = 0;
+async function loadModule() {
+ _instance += 1;
+ return import(MODULE_URL + '?case=' + _instance);
+}
+
+function loaderSnapshot(loader) {
+ return {
+ revealed: loader.dataset.shellRevealed === 'true',
+ opacity: loader.style.opacity ?? null,
+ pointerEvents: loader.style.pointerEvents ?? null,
+ ariaHidden: loader.getAttribute('aria-hidden') ?? null,
+ removed: loader.removed,
+ };
+}
+
+const cases = {};
+
+cases.reveal_waits_one_paint_then_keeps_node = async () => {
+ const w = makeWorld();
+ const loader = w.addElement('app-loader');
+ const shell = await loadModule();
+ shell.revealApplicationShellAfterPaint();
+ const beforePaint = loaderSnapshot(loader);
+ w.paint(1);
+ const afterOneFrame = loaderSnapshot(loader);
+ w.paint(1);
+ return {
+ beforePaint,
+ afterOneFrame,
+ afterPaint: loaderSnapshot(loader),
+ waveStops: w.waveStops,
+ stillInDocument: w.byId.has('app-loader'),
+ };
+};
+
+cases.reveal_is_idempotent = async () => {
+ const w = makeWorld();
+ const loader = w.addElement('app-loader');
+ const shell = await loadModule();
+ shell.revealApplicationShellAfterPaint();
+ shell.revealApplicationShellAfterPaint();
+ w.paint(2);
+ shell.revealApplicationShellAfterPaint();
+ w.paint(2);
+ return { waveStops: w.waveStops, snapshot: loaderSnapshot(loader) };
+};
+
+cases.remove_retires_the_loader_node = async () => {
+ const w = makeWorld();
+ const loader = w.addElement('app-loader');
+ const shell = await loadModule();
+ shell.removeApplicationLoader();
+ const beforeTimers = loaderSnapshot(loader);
+ w.runTimers();
+ return { beforeTimers, afterTimers: loaderSnapshot(loader) };
+};
+
+cases.failed_hydration_marks_sidebar_row = async () => {
+ const w = makeWorld();
+ w.addElement('app-loader');
+ const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
+ const shell = await loadModule();
+ await shell.settleSessionHydration(() => Promise.reject(new Error('boom')));
+ const beforePaint = row.status.textContent;
+ w.paint(2);
+ w.runTimers();
+ return {
+ beforePaint,
+ afterPaint: row.status.textContent,
+ loaderRemoved: !w.byId.has('app-loader'),
+ };
+};
+
+// A successful load with zero sessions must not schedule a failure write.
+cases.zero_session_success_shows_no_failure = async () => {
+ const w = makeWorld();
+ w.addElement('app-loader');
+ const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
+ const shell = await loadModule();
+ await shell.settleSessionHydration(() => Promise.resolve(true));
+ w.paint(1);
+ row.remove(); // renderSessionList() clearing #session-list
+ w.paint(1);
+ return { statusText: row.status.textContent, rowRemoved: row.removed };
+};
+
+// The whole point is getting /api/sessions off the critical path, not later.
+cases.hydration_starts_synchronously = async () => {
+ const w = makeWorld();
+ w.addElement('app-loader');
+ const shell = await loadModule();
+ let started = false;
+ const done = shell.settleSessionHydration(() => { started = true; return Promise.resolve(true); });
+ const startedBeforeAwait = started;
+ await done;
+ return { startedBeforeAwait };
+};
+
+cases.synchronous_load_failure_still_settles = async () => {
+ const w = makeWorld();
+ w.addElement('app-loader');
+ const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
+ const shell = await loadModule();
+ let opened = 0;
+ shell.deferRouteOpener('/email', () => { opened += 1; });
+ let threw = false;
+ let succeeded = true;
+ try {
+ succeeded = await shell.settleSessionHydration(() => { throw new Error('module blew up'); });
+ } catch (_) { threw = true; }
+ w.paint(2);
+ w.runTimers();
+ return {
+ threw,
+ succeeded,
+ opened,
+ statusText: row.status.textContent,
+ loaderRemoved: !w.byId.has('app-loader'),
+ ranAfterFailure: shell.runDeferredRouteOpener({ sessionsSettled: true }),
+ };
+};
+
+cases.route_without_session_data_opens_before_hydration = async () => {
+ const w = makeWorld();
+ w.addElement('app-loader');
+ const shell = await loadModule();
+ let opened = 0;
+ shell.deferRouteOpener('/notes', () => { opened += 1; });
+ const ranEarly = shell.runDeferredRouteOpener();
+ const openedAfterEarly = opened;
+ const ranAgain = shell.runDeferredRouteOpener({ sessionsSettled: true });
+ return { ranEarly, openedAfterEarly, ranAgain, opened };
+};
+
+cases.route_with_session_data_waits_for_hydration = async () => {
+ const w = makeWorld();
+ w.addElement('app-loader');
+ const shell = await loadModule();
+ let opened = 0;
+ shell.deferRouteOpener('/email', () => { opened += 1; });
+ const ranEarly = shell.runDeferredRouteOpener();
+ const openedAfterEarly = opened;
+ const succeeded = await shell.settleSessionHydration(() => Promise.resolve(true));
+ return {
+ ranEarly,
+ openedAfterEarly,
+ openedAfterHydration: opened,
+ succeeded,
+ needsSessions: [shell.routeNeedsSessionData('/email'), shell.routeNeedsSessionData('/notes')],
+ };
+};
+
+cases.missing_session_module_keeps_route_deferred = async () => {
+ const w = makeWorld();
+ w.addElement('app-loader');
+ const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
+ const shell = await loadModule();
+ let opened = 0;
+ shell.deferRouteOpener('/email', () => { opened += 1; });
+ const succeeded = await shell.settleSessionHydration(null);
+ w.paint(2);
+ w.runTimers();
+ return {
+ opened,
+ succeeded,
+ statusText: row.status.textContent,
+ loaderRemoved: !w.byId.has('app-loader'),
+ ranAfterFailure: shell.runDeferredRouteOpener({ sessionsSettled: true }),
+ };
+};
+
+cases.throwing_route_opener_is_contained = async () => {
+ const w = makeWorld();
+ w.addElement('app-loader');
+ const shell = await loadModule();
+ shell.deferRouteOpener('/notes', () => { throw new Error('opener blew up'); });
+ let threw = false;
+ let ran = false;
+ try { ran = shell.runDeferredRouteOpener(); } catch (_) { threw = true; }
+ return { threw, ran, ranAgain: shell.runDeferredRouteOpener({ sessionsSettled: true }) };
+};
+
+const out = {};
+for (const [name, fn] of Object.entries(cases)) out[name] = await fn();
+console.log(JSON.stringify(out));
+""".replace("MODULE_PATH", _MODULE_URL)
+
+
+@pytest.fixture(scope="module")
+def results():
+ if not _HAS_NODE:
+ pytest.skip("node is not installed")
+ proc = subprocess.run(
+ ["node", "--input-type=module", "-e", _HARNESS],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ assert proc.returncode == 0, f"node harness failed:\n{proc.stderr}"
+ return json.loads(proc.stdout.strip().splitlines()[-1])
+
+
+def test_module_exists():
+ assert _MODULE.is_file(), f"missing {_MODULE}"
+
+
+def test_shell_is_revealed_one_paint_after_wiring(results):
+ r = results["reveal_waits_one_paint_then_keeps_node"]
+ assert r["beforePaint"]["revealed"] is False, "revealed before any frame ran"
+ assert r["afterOneFrame"]["revealed"] is False, "revealed before the paint committed"
+ assert r["afterPaint"] == {
+ "revealed": True,
+ "opacity": "0",
+ "pointerEvents": "none",
+ "ariaHidden": "true",
+ "removed": False,
+ }
+ assert r["waveStops"] == 1, "loader wave interval kept running after reveal"
+
+
+def test_revealed_loader_stays_as_startup_sentinel(results):
+ # sessions.js / sidebar-layout.js read #app-loader as "startup in progress".
+ r = results["reveal_waits_one_paint_then_keeps_node"]
+ assert r["stillInDocument"] is True
+ assert r["afterPaint"]["removed"] is False
+
+
+def test_reveal_is_idempotent(results):
+ r = results["reveal_is_idempotent"]
+ assert r["waveStops"] == 1, "reveal ran its side effects more than once"
+ assert r["snapshot"]["revealed"] is True
+
+
+def test_loader_node_is_retired_after_the_fade(results):
+ r = results["remove_retires_the_loader_node"]
+ assert r["beforeTimers"]["revealed"] is True, "removal should hide immediately"
+ assert r["beforeTimers"]["removed"] is False, "removal should wait for the fade"
+ assert r["afterTimers"]["removed"] is True, "loader node outlived hydration"
+
+
+def test_failed_session_load_marks_the_sidebar_row(results):
+ r = results["failed_hydration_marks_sidebar_row"]
+ assert r["beforePaint"] == "Loading chats…", "failure written before the render frame"
+ assert r["afterPaint"] == "Chats unavailable"
+ assert r["loaderRemoved"] is True, "a failed load must still free the shell"
+
+
+def test_zero_session_success_never_shows_a_failure(results):
+ r = results["zero_session_success_shows_no_failure"]
+ assert r["rowRemoved"] is True
+ assert r["statusText"] == "Loading chats…", "false 'Chats unavailable' on empty success"
+
+
+def test_hydration_request_starts_synchronously(results):
+ r = results["hydration_starts_synchronously"]
+ assert r["startedBeforeAwait"] is True, "/api/sessions start was deferred a microtask"
+
+
+def test_synchronous_load_failure_still_settles(results):
+ r = results["synchronous_load_failure_still_settles"]
+ assert r["threw"] is False, "a throwing loadSessions must not escape"
+ assert r["succeeded"] is False
+ assert r["opened"] == 0, "session-dependent route opened without session data"
+ assert r["ranAfterFailure"] is False, "failed startup left a stale route opener"
+ assert r["statusText"] == "Chats unavailable"
+ assert r["loaderRemoved"] is True
+
+
+def test_route_needing_no_session_data_opens_before_hydration(results):
+ r = results["route_without_session_data_opens_before_hydration"]
+ assert r["ranEarly"] is True, "/notes waited on /api/sessions it does not read"
+ assert r["openedAfterEarly"] == 1
+ assert r["ranAgain"] is False, "route opener fired twice"
+ assert r["opened"] == 1
+
+
+def test_route_needing_session_data_waits_for_hydration(results):
+ r = results["route_with_session_data_waits_for_hydration"]
+ assert r["ranEarly"] is False, "/email opened before the session list was there"
+ assert r["openedAfterEarly"] == 0
+ assert r["openedAfterHydration"] == 1
+ assert r["succeeded"] is True
+ assert r["needsSessions"] == [True, False]
+
+
+def test_missing_session_module_still_settles_without_opening_data_route(results):
+ r = results["missing_session_module_keeps_route_deferred"]
+ assert r["succeeded"] is False
+ assert r["opened"] == 0, "route opened without the session module it depends on"
+ assert r["ranAfterFailure"] is False, "missing module left a stale route opener"
+ assert r["statusText"] == "Chats unavailable"
+ assert r["loaderRemoved"] is True
+
+
+def test_throwing_route_opener_is_contained(results):
+ r = results["throwing_route_opener_is_contained"]
+ assert r["threw"] is False
+ assert r["ran"] is True
+ assert r["ranAgain"] is False, "a failed opener must not be retried"