diff --git a/web/app.js b/web/app.js
index 81d8bc6..e280496 100644
--- a/web/app.js
+++ b/web/app.js
@@ -38,6 +38,15 @@ import { LoginPage } from './components/login-page.js';
import './lib/open-file.js';
import './lib/open-tool.js';
import { initI18n } from './lib/i18n.js';
+import { installSessionExpiryWatch } from './lib/session-expiry.js';
+import { installSessionRelogin } from './components/session-relogin.js';
+
+// Installed before anything can call the API, so no component's first fetch
+// escapes the 401 watch. A session that dies under an open tab is renewed in a
+// modal over the page, leaving the app's state — an unsent message included —
+// exactly where it was.
+installSessionExpiryWatch();
+installSessionRelogin();
customElements.define('app-topbar', AppTopbar);
customElements.define('app-sidebar', AppSidebar);
diff --git a/web/components/login-page.js b/web/components/login-page.js
index 6f0bc3f..ec4a4be 100644
--- a/web/components/login-page.js
+++ b/web/components/login-page.js
@@ -50,6 +50,9 @@ export class LoginPage extends I18nMixin(LightElement) {
this._error = t('login.error');
return;
}
+ // Remembered only to prefill the re-login dialog when this browser's
+ // session dies under an open tab — never a credential, just the name.
+ try { localStorage.setItem('skald.last_user', this._username.trim()); } catch { /* private mode */ }
// Logged in — reload into the app.
window.location.reload();
} catch {
diff --git a/web/components/mobile-app.js b/web/components/mobile-app.js
index 26b3022..cec20c5 100644
--- a/web/components/mobile-app.js
+++ b/web/components/mobile-app.js
@@ -1,6 +1,8 @@
import { LitElement, html, nothing } from 'lit';
import { t } from '../lib/i18n.js';
import { LoginPage } from './login-page.js';
+import { installSessionExpiryWatch } from '../lib/session-expiry.js';
+import { installSessionRelogin } from './session-relogin.js';
import './shared/inbox-page.js';
import './shared/chat-page.js';
import './shared/projects-page.js';
@@ -10,6 +12,12 @@ import './shared/tool-detail-mobile.js';
customElements.define('login-page', LoginPage);
+// A session lost mid-use is renewed in place, over whatever section is open —
+// same seam as the desktop shell. Both are no-ops in the native shell, which
+// authenticates on its own.
+installSessionExpiryWatch();
+installSessionRelogin();
+
// Sections addressable via the URL hash — same routing style as the desktop
// sidebar (web/components/sidebar.js). The native iOS shell and mobile browsers
// share this router so the URL always reflects the active section: native menu
diff --git a/web/components/session-relogin.js b/web/components/session-relogin.js
new file mode 100644
index 0000000..4cc1ae9
--- /dev/null
+++ b/web/components/session-relogin.js
@@ -0,0 +1,160 @@
+import { html, nothing } from 'lit';
+import { LightElement } from '../lib/base.js';
+import { t, I18nMixin } from '../lib/i18n.js';
+import { notifySessionRestored } from '../lib/session-expiry.js';
+
+/**
+ * Re-login dialog for a session that died under an open tab.
+ *
+ * Sessions live in the server's RAM (blueprint §9), so a restart logs everyone
+ * out while their browser keeps sending a cookie nobody recognises. The obvious
+ * answer — bounce to the login screen — throws away everything the page was
+ * holding, and the composer's half-written message with it. So the session is
+ * renewed **in place**: a modal over the page the user was already on, one
+ * password field, and on success the app carries on with its state intact (the
+ * chat reconnects on `auth-restored`).
+ *
+ * Not dismissible, deliberately: with no session nothing on the page works, and
+ * a dialog you can wave away would leave a UI that silently fails every action.
+ */
+export class SessionRelogin extends I18nMixin(LightElement) {
+
+ static get properties() {
+ return {
+ _open: { state: true },
+ _username: { state: true },
+ _password: { state: true },
+ _error: { state: true },
+ _busy: { state: true },
+ };
+ }
+
+ constructor() {
+ super();
+ this._open = false;
+ this._username = '';
+ this._password = '';
+ this._error = null;
+ this._busy = false;
+ }
+
+ /** Raise the dialog, prefilling the last username known to this browser. */
+ open() {
+ if (this._open) return;
+ let last = '';
+ try { last = localStorage.getItem('skald.last_user') ?? ''; } catch { /* private mode */ }
+ this._username = last;
+ this._password = '';
+ this._error = null;
+ this._open = true;
+ // Focus the field the user actually has to fill: the password when we
+ // already know who they are, the username otherwise.
+ this.updateComplete.then(() => {
+ const sel = last ? 'input[type="password"]' : 'input[type="text"]';
+ this.querySelector(sel)?.focus();
+ });
+ }
+
+ _submit(e) {
+ e.preventDefault();
+ if (this._busy) return;
+ this._error = null;
+ if (!this._username.trim() || !this._password) {
+ this._error = t('login.missing');
+ return;
+ }
+ this._doLogin();
+ }
+
+ async _doLogin() {
+ this._busy = true;
+ try {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ username: this._username.trim(),
+ password: this._password,
+ }),
+ });
+ if (!res.ok) {
+ this._error = t('login.error');
+ return;
+ }
+ try { localStorage.setItem('skald.last_user', this._username.trim()); } catch { /* private mode */ }
+ this._password = '';
+ this._open = false;
+ // The new cookie is live: tell the app to pick its connections back up.
+ notifySessionRestored();
+ } catch {
+ this._error = t('login.network');
+ } finally {
+ this._busy = false;
+ }
+ }
+
+ render() {
+ if (!this._open) return nothing;
+ const btnLabel = this._busy
+ ? html`${t('login.signing')}`
+ : t('login.submit');
+
+ return html`
+
+ `;
+ }
+}
+
+/**
+ * Mount the dialog and wire it to `auth-expired`.
+ *
+ * The element appends itself to `` rather than living in the two shells'
+ * HTML: it belongs to whichever page happens to be open, and desktop and mobile
+ * would otherwise each have to remember to declare it.
+ */
+export function installSessionRelogin() {
+ if (!customElements.get('session-relogin')) {
+ customElements.define('session-relogin', SessionRelogin);
+ }
+ let el = null;
+ window.addEventListener('auth-expired', () => {
+ if (!el) {
+ el = document.createElement('session-relogin');
+ document.body.appendChild(el);
+ }
+ el.open();
+ });
+}
diff --git a/web/css/setup-page.css b/web/css/setup-page.css
index d26fac6..6f14123 100644
--- a/web/css/setup-page.css
+++ b/web/css/setup-page.css
@@ -105,3 +105,41 @@
.login-spinner {
animation-name: setup-spin;
}
+
+/* ── Re-login dialog ──────────────────────────────────────────────────────────
+ Raised over the page the user is already on when their session dies (see
+ components/session-relogin.js). Deliberately a scrim rather than an opaque
+ screen: seeing the work still there is the whole point of not navigating
+ away. Reuses `.login-card` so the two login surfaces cannot drift apart. */
+
+.relogin-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 10000; /* above the login screen's own layer */
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: rgba(0, 0, 0, .5);
+ backdrop-filter: blur(2px);
+ overflow: auto;
+}
+
+.relogin-card {
+ max-width: 400px;
+ padding: 28px 28px 24px;
+}
+
+.relogin-title {
+ font-size: 1.15rem;
+ margin-bottom: 10px;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .relogin-card { animation: relogin-in .18s ease-out; }
+}
+
+@keyframes relogin-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: none; }
+}
diff --git a/web/i18n/en.js b/web/i18n/en.js
index 7535201..bac3c8a 100644
--- a/web/i18n/en.js
+++ b/web/i18n/en.js
@@ -79,6 +79,7 @@ export default {
'chat.rejected': 'Denied.',
'chat.rejected_by_user': 'Denied by user.',
'chat.truncated': 'Response truncated by the token limit (↓{tokens} tok).',
+ 'chat.not_connected': 'Not connected — reconnecting. Your message is still in the box: press Enter again in a moment.',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Open in viewer',
@@ -307,6 +308,8 @@ export default {
// ── Login ──────────────────────────────────────────────────────────────────
'login.title': 'Welcome back',
'login.subtitle': 'Sign in to your account.',
+ 'login.expired.title': 'Session expired',
+ 'login.expired': 'The server was restarted, so your session ended. Sign in again to carry on — the page keeps everything you had open, including any unsent message.',
'login.username': 'Username',
'login.password': 'Password',
'login.submit': 'Sign in',
diff --git a/web/i18n/fr.js b/web/i18n/fr.js
index 3603244..43f9f5c 100644
--- a/web/i18n/fr.js
+++ b/web/i18n/fr.js
@@ -79,6 +79,7 @@ export default {
'chat.rejected': 'Refusé.',
'chat.rejected_by_user': 'Refusé par l\'utilisateur.',
'chat.truncated': 'Réponse tronquée par la limite de tokens (↓{tokens} tok).',
+ 'chat.not_connected': 'Non connecté — reconnexion en cours. Votre message est resté dans le champ : appuyez de nouveau sur Entrée dans un instant.',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Ouvrir dans le visualiseur',
@@ -307,6 +308,8 @@ export default {
// ── Login ──────────────────────────────────────────────────────────────────
'login.title': 'Bon retour',
'login.subtitle': 'Connectez-vous à votre compte.',
+ 'login.expired.title': 'Session expirée',
+ 'login.expired': 'Le serveur a redémarré, votre session a donc pris fin. Reconnectez-vous pour continuer : la page reste telle quelle, message non envoyé compris.',
'login.username': 'Nom d\'utilisateur',
'login.password': 'Mot de passe',
'login.submit': 'Se connecter',
diff --git a/web/i18n/it.js b/web/i18n/it.js
index 46456a7..1944834 100644
--- a/web/i18n/it.js
+++ b/web/i18n/it.js
@@ -79,6 +79,7 @@ export default {
'chat.rejected': 'Negata.',
'chat.rejected_by_user': 'Negata dall\'utente.',
'chat.truncated': 'Risposta troncata dal limite di token (↓{tokens} tok).',
+ 'chat.not_connected': 'Non connesso — riconnessione in corso. Il messaggio è rimasto nella casella: premi di nuovo Invio tra un istante.',
// ── Copilot render ─────────────────────────────────────────────────────────
'copilot.open_in_viewer': 'Apri nel visualizzatore',
@@ -307,6 +308,8 @@ export default {
// ── Accesso ────────────────────────────────────────────────────────────────
'login.title': 'Bentornato',
'login.subtitle': 'Accedi al tuo account.',
+ 'login.expired.title': 'Sessione scaduta',
+ 'login.expired': 'Il server è stato riavviato e la sessione è terminata. Accedi di nuovo per proseguire: la pagina resta com\'era, messaggio non inviato compreso.',
'login.username': 'Nome utente',
'login.password': 'Password',
'login.submit': 'Accedi',
diff --git a/web/lib/chat-session.js b/web/lib/chat-session.js
index ceb4052..a597cd7 100644
--- a/web/lib/chat-session.js
+++ b/web/lib/chat-session.js
@@ -1,6 +1,7 @@
import { html, nothing } from 'lit';
import { LightElement } from './base.js';
import { t } from './i18n.js';
+import { isSessionExpired, notifySessionExpired, probeSession } from './session-expiry.js';
// Slash commands handled entirely server-side: they reply with a `Done` and never
// echo back as a `user_message`, so they are the only commands rendered
@@ -109,10 +110,12 @@ export class ChatSession extends LightElement {
// Each entry: { name, path, mimetype, filesize, uploading? }. While an upload
// is in flight the entry has `uploading: true` and no `path` yet.
this._attachments = [];
+ this._onAuthRestored = this._onAuthRestored.bind(this);
}
async connectedCallback() {
super.connectedCallback();
+ window.addEventListener('auth-restored', this._onAuthRestored);
// Fire-and-forget: availability of a transcription provider determines
// whether the mic button is rendered at all.
this._checkTranscribe();
@@ -120,6 +123,11 @@ export class ChatSession extends LightElement {
this._connectWS();
}
+ disconnectedCallback() {
+ super.disconnectedCallback?.();
+ window.removeEventListener('auth-restored', this._onAuthRestored);
+ }
+
// ── Source identity — override in subclass ────────────────────────────────────
// Static default source for this component. Subclasses override (e.g. 'mobile').
@@ -223,7 +231,38 @@ export class ChatSession extends LightElement {
}
};
ws.onmessage = (ev) => this._handleServerMsg(JSON.parse(ev.data));
- ws.onclose = () => { this._reconnecting = true; setTimeout(() => this._connectWS(), 2000); };
+ ws.onclose = () => { this._reconnecting = true; this._scheduleReconnect(); };
+ }
+
+ /**
+ * Reconnect after an unexpected close — unless we were dropped because the
+ * server no longer knows this browser. Sessions live in the server's RAM
+ * (blueprint §9), so a restart refuses the WS upgrade, and a refused upgrade
+ * reaches `onclose` looking exactly like a flaky network: the loop used to
+ * retry every 2 s forever behind "Not connected", against a server that would
+ * never accept it again. So ask first, and let the re-login dialog take it
+ * from there — the socket comes back on `auth-restored`.
+ */
+ async _scheduleReconnect() {
+ if (isSessionExpired()) return; // the dialog is already up
+ if ((await probeSession()) === 'expired') {
+ notifySessionExpired();
+ // A shell that handles auth itself (native mobile) ignores the report;
+ // there is no dialog coming, so keep retrying as before.
+ if (isSessionExpired()) return;
+ }
+ setTimeout(() => this._connectWS(), 2000);
+ }
+
+ /**
+ * A new session was obtained without leaving the page: reconnect and reconcile
+ * like any other unexpected disconnection (the `_reconnecting` flag is what
+ * makes `onopen` re-sync tool state that advanced while we were away).
+ */
+ _onAuthRestored() {
+ if (this._ws && this._ws.readyState !== WebSocket.CLOSED) return;
+ this._reconnecting = true;
+ this._connectWS();
}
/**
@@ -665,14 +704,26 @@ export class ChatSession extends LightElement {
// Don't send while an attachment is still streaming to disk, or its path
// would be missing from the message.
if (this._attachments.some(a => a.uploading)) return;
- this._clearInput();
-
+ // Handled entirely over HTTP (and it reconnects the socket itself), so it must
+ // stay available precisely when the socket is down.
if (content === '/new' || content === '/clear') {
+ this._clearInput();
this._attachments = [];
await this._startNewSession();
return;
}
+ // Nothing below this point may run while the socket is down: everything from
+ // here on is destructive to what the user typed (the input is cleared, the
+ // attachment chips are dropped). Bailing first is what keeps a long message
+ // recoverable — the composer still holds it, so retrying after the automatic
+ // reconnect is one Enter, not a retype.
+ if (this._ws?.readyState !== WebSocket.OPEN) {
+ this._pushError(t('chat.not_connected'));
+ return;
+ }
+ this._clearInput();
+
// Strip client-only fields; the server persists these as message metadata.
const attachments = this._attachments.map(({ name, path, mimetype, filesize }) =>
({ name, path, mimetype, filesize }));
@@ -692,13 +743,7 @@ export class ChatSession extends LightElement {
this._push({ kind: 'user', content, attachments });
}
this._waiting = true;
-
- if (this._ws?.readyState === WebSocket.OPEN) {
- this._ws.send(JSON.stringify({ content, attachments }));
- } else {
- this._pushError('Not connected — reconnecting, please retry.');
- this._waiting = false;
- }
+ this._ws.send(JSON.stringify({ content, attachments }));
}
// ── Attachments ────────────────────────────────────────────────────────────
diff --git a/web/lib/session-expiry.js b/web/lib/session-expiry.js
new file mode 100644
index 0000000..4443e65
--- /dev/null
+++ b/web/lib/session-expiry.js
@@ -0,0 +1,96 @@
+/**
+ * Session-expiry detection for an already-open tab.
+ *
+ * A session lives in the server's RAM (blueprint §9), so a restart invalidates
+ * every token while the browser keeps happily sending its cookie. From that
+ * moment every gated `/api` call answers 401 and the chat socket is refused at
+ * the upgrade — and a refused upgrade is indistinguishable, in `onclose`, from a
+ * dropped wifi. That is why the chat used to sit forever behind "Not connected —
+ * reconnecting": it was reconnecting, correctly, to a server that will never
+ * accept it again.
+ *
+ * This module is the single place that turns "the server says I am nobody" into
+ * a fact the app can act on: the `auth-expired` event, answered by the re-login
+ * dialog (`components/session-relogin.js`), and `auth-restored` once a new
+ * session is in hand. Nothing here touches the DOM — reporting and reacting stay
+ * apart, so a second reactor (a banner, a mobile-specific screen) costs nothing.
+ */
+
+let expired = false;
+
+// The native mobile shell authenticates in the background and must never be
+// gated by a web login form (the rule `mobile.html`'s bootstrap already states).
+// Guarding the report rather than each producer means no future caller can
+// reintroduce the dialog there.
+const NATIVE_SHELL = new URLSearchParams(location.search).get('native') === 'true';
+
+/** True once the server has told us this browser has no session anymore. */
+export function isSessionExpired() {
+ return expired;
+}
+
+/**
+ * Report a lost session. Idempotent and one-way: the first call fires the
+ * `auth-expired` window event, every later one is a no-op — several components
+ * discover the same 401 at once, and the dialog must be raised once.
+ */
+export function notifySessionExpired() {
+ if (expired || NATIVE_SHELL) return;
+ expired = true;
+ window.dispatchEvent(new CustomEvent('auth-expired'));
+}
+
+/**
+ * Report that a fresh session has been obtained (the re-login dialog succeeded).
+ * Re-arms the detector and fires `auth-restored`, on which the live connections
+ * that gave up — the chat socket above all — pick themselves back up.
+ */
+export function notifySessionRestored() {
+ if (!expired) return;
+ expired = false;
+ window.dispatchEvent(new CustomEvent('auth-restored'));
+}
+
+/**
+ * Ask the server whether this browser still has a session.
+ *
+ * Returns `'ok'`, `'expired'`, or `'unknown'` when the server could not be
+ * reached — the caller must treat that third case as "keep retrying", never as a
+ * logout: a box that is merely down comes back, and throwing the user at a login
+ * form they cannot submit would be strictly worse than waiting.
+ */
+export async function probeSession() {
+ try {
+ const res = await fetch('/api/auth/me');
+ if (res.status === 401) return 'expired';
+ return res.ok ? 'ok' : 'unknown';
+ } catch {
+ return 'unknown';
+ }
+}
+
+/**
+ * Wrap `window.fetch` so that a 401 from any gated `/api` endpoint reports an
+ * expired session, wherever in the app it happens.
+ *
+ * A wrapper rather than a helper every call site opts into: the components call
+ * `fetch` directly in dozens of places, and a seam that has to be remembered is
+ * one that will be forgotten by the next page. The auth endpoints are excluded
+ * because 401 is a *normal* answer there — `auth/me` is the "am I logged in?"
+ * probe and `auth/login` answers it to a wrong password; treating either as an
+ * expiry would raise the login screen from the login screen.
+ */
+export function installSessionExpiryWatch() {
+ const native = window.fetch.bind(window);
+ window.fetch = async (input, init) => {
+ const res = await native(input, init);
+ if (res.status === 401) {
+ const url = typeof input === 'string' ? input : (input?.url ?? '');
+ const path = url.startsWith('http') ? new URL(url).pathname : url;
+ if (path.startsWith('/api/') && !path.startsWith('/api/auth/') && !path.startsWith('/api/setup/')) {
+ notifySessionExpired();
+ }
+ }
+ return res;
+ };
+}