Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
10 changed files with 378 additions and 10 deletions
Showing only changes of commit ff298f1aef - Show all commits
+9
View File
@@ -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);
+3
View File
@@ -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 {
+8
View File
@@ -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
+160
View File
@@ -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`<span class="login-spinner"></span>${t('login.signing')}`
: t('login.submit');
return html`
<div class="relogin-backdrop">
<form class="login-card relogin-card" @submit=${this._submit} autocomplete="on">
<h2 class="login-title relogin-title">${t('login.expired.title')}</h2>
<p class="login-subtitle">${t('login.expired')}</p>
${this._error ? html`<div class="login-error">${this._error}</div>` : null}
<div class="mb-3">
<label class="form-label">${t('login.username')}</label>
<input
type="text"
class="form-control"
autocomplete="username"
.value=${this._username}
@input=${e => this._username = e.target.value}
?disabled=${this._busy} />
</div>
<div class="mb-3">
<label class="form-label">${t('login.password')}</label>
<input
type="password"
class="form-control"
autocomplete="current-password"
.value=${this._password}
@input=${e => this._password = e.target.value}
?disabled=${this._busy} />
</div>
<button type="submit" class="btn btn-primary login-submit" ?disabled=${this._busy}>
${btnLabel}
</button>
</form>
</div>
`;
}
}
/**
* Mount the dialog and wire it to `auth-expired`.
*
* The element appends itself to `<body>` 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();
});
}
+38
View File
@@ -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; }
}
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+54 -9
View File
@@ -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;
}
}
// ── Attachments ────────────────────────────────────────────────────────────
+96
View File
@@ -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;
};
}