Files
Skald-Circle/web/components/mobile-app.js
T
dguiducci ff298f1aef
Nightly Build / build (push) Successful in 7m34s
fix: renew a session that died under an open tab, instead of eating the message typed into it
Sessions live in the server's RAM, so a restart logs everyone out while the
browser keeps sending a cookie nobody recognises. Nothing noticed: every gated
API call answered 401 into a component that shrugged, and the chat socket was
refused at the upgrade — which reaches `onclose` looking exactly like a flaky
network, so the loop retried every 2 s forever behind "Not connected —
reconnecting, please retry", against a server that would never accept it again.

Retrying was not even the expensive part. `_send()` cleared the composer and
dropped the attachment chips *before* testing the socket, so a long message was
already destroyed by the time the error bubble appeared. The connection test now
comes first and everything below it is unreachable while the socket is down, so
the text stays where the user left it; `/new` and `/clear` move above the guard
because they go over HTTP and reconnect the socket themselves, which is when
they are most wanted.

Detection is one module (`lib/session-expiry.js`) reporting a fact — `auth-expired`,
and `auth-restored` on the way back — with nothing in it that touches the DOM. A
`window.fetch` wrapper flags any 401 from a gated `/api` path, a wrapper rather
than a helper each call site opts into because the components call `fetch`
directly in dozens of places and a seam that must be remembered is one the next
page will forget; `auth/*` and `setup/*` are excluded, where 401 is the normal
answer. The socket's own path asks `probeSession()` before retrying, since it
cannot tell a refusal from a blip. The native mobile shell is guarded inside the
report, so no future caller can reintroduce a web login form there.

The answer is a modal over the page the user is already on, not the login
screen: bouncing to it would throw away everything the page was holding —
including the half-written message this commit exists to save. One password
field, prefilled with the last username this browser logged in as, not
dismissible (with no session nothing on the page works, and a dialog you can
wave away leaves a UI that silently fails every action). On success the chat
reconnects on `auth-restored` and reconciles like any other disconnection.

Known gap: pages that failed a fetch during the outage keep their stale data
until navigated to again. Only the chat re-arms itself.
2026-08-04 13:02:39 +01:00

254 lines
11 KiB
JavaScript

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';
import './shared/settings-page.js';
import './shared/file-viewer-mobile.js';
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
// sync, deep links, and back/refresh restoration all flow from one place.
// `file_viewer` / `tool_detail` are not tabs — they're opened from content (a
// clickable tool path via openFile() → `#file_viewer?path=...`, or a tool card's
// eye via openToolDetail() → `#tool_detail?id=...`) and have no bottom-nav entry.
const VALID_SECTIONS = ['inbox', 'projects', 'chat', 'notifications', 'settings', 'file_viewer', 'tool_detail'];
class MobileApp extends LitElement {
// No shadow DOM — lets external CSS and Bootstrap Icons apply directly.
createRenderRoot() { return this; }
static properties = {
_section: { state: true },
// Source the chat is bound to: 'mobile' (main) or 'project-{id}'.
_chatSource: { state: true },
// Label shown in the chat header when inside a project.
_chatLabel: { state: true },
// File shown by the file_viewer section (from `#file_viewer?path=...`).
_filePath: { state: true },
// Tool call shown by the tool_detail section (from `#tool_detail?id=...`).
_toolId: { state: true },
};
constructor() {
super();
this._section = 'chat';
this._chatSource = 'mobile';
this._chatLabel = '';
this._filePath = null;
this._toolId = null;
// id → name cache, so a cold deep-link (#chat/project-<id> opened by the
// native shell) can resolve its header label without the project list open.
this._projectLabels = {};
// Native shell mode (?native=true): the HTML bottom nav is hidden — a native
// tab bar drives navigation via location.hash. Mark the host so CSS can drop
// the safe-area insets the native chrome already provides.
this._native = new URLSearchParams(location.search).get('native') === 'true';
if (this._native) this.setAttribute('data-native', '');
}
connectedCallback() {
super.connectedCallback();
this._onHashChange = () => this._applyHash();
window.addEventListener('hashchange', this._onHashChange);
window.addEventListener('popstate', this._onHashChange);
// Default route when no hash is present (replaceState: no history entry).
if (!location.hash) history.replaceState(null, '', '#chat');
this._applyHash();
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('hashchange', this._onHashChange);
window.removeEventListener('popstate', this._onHashChange);
}
// ── Hash routing ───────────────────────────────────────────────────────────
// { section, projectId, filePath } parsed from location.hash. Forms:
// #projects → section 'projects'
// #chat → section 'chat' (main mobile session)
// #chat/project-<id> → section 'chat' bound to a project's session
// #file_viewer?path=<enc> → section 'file_viewer' showing a file
_readHash() {
const raw = location.hash.slice(1);
if (!raw) return { section: 'chat', projectId: null, filePath: null, toolId: null };
// Segment ends at the first `/` (project sub-route) or `?` (query, e.g. the
// file viewer's `?path=` / the tool detail's `?id=`).
const cut = raw.search(/[/?]/);
const seg = cut === -1 ? raw : raw.slice(0, cut);
const section = VALID_SECTIONS.includes(seg) ? seg : 'chat';
if (section === 'file_viewer') {
let filePath = null;
const m = raw.match(/[?&]path=([^&]*)/);
if (m) { try { filePath = decodeURIComponent(m[1]); } catch { /* keep null */ } }
return { section, projectId: null, filePath, toolId: null };
}
if (section === 'tool_detail') {
let toolId = null;
const m = raw.match(/[?&]id=([^&]*)/);
if (m) { try { toolId = decodeURIComponent(m[1]); } catch { /* keep null */ } }
return { section, projectId: null, filePath: null, toolId };
}
const slash = raw.indexOf('/');
const sub = slash === -1 ? '' : raw.slice(slash + 1);
let projectId = null;
if (section === 'chat' && sub.startsWith('project-')) {
projectId = sub.slice('project-'.length) || null;
}
return { section, projectId, filePath: null, toolId: null };
}
_applyHash() {
const { section, projectId, filePath, toolId } = this._readHash();
this._section = section;
this._filePath = filePath;
this._toolId = toolId;
if (projectId) {
const source = 'project-' + projectId;
if (this._chatSource !== source) this._chatSource = source;
this._resolveLabel(projectId);
} else if (section === 'chat') {
if (this._chatSource !== 'mobile') this._chatSource = 'mobile';
this._chatLabel = '';
}
// Tell the native shell which section is active. For the file viewer we also
// pass the document path, so the iOS "Doc" tab can highlight itself and
// remember the last opened file (re-opened when the user taps Doc again).
this._notifyNative(
section,
projectId ? 'project-' + projectId : null,
section === 'file_viewer' ? filePath : null,
);
}
// Resolve the display label for a project id (shown in the chat header). Cached
// in _projectLabels; fetched once from /api/projects when first needed (e.g. a
// cold native deep-link), then served from cache on every subsequent switch.
async _resolveLabel(projectId) {
if (this._projectLabels[projectId] != null) {
this._chatLabel = this._projectLabels[projectId];
return;
}
try {
const res = await fetch('/api/projects');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
for (const p of await res.json()) this._projectLabels[p.id] = p.name;
} catch { /* keep whatever label we have */ }
this._chatLabel = this._projectLabels[projectId] ?? projectId;
}
_nav(section) {
const target = '#' + section;
// Setting the hash to the current value fires no event; only change when it
// differs (also covers exiting a project sub-route via the chat tab).
if (location.hash !== target) location.hash = target;
}
// A project was tapped in the projects list: re-point the chat to its source
// and push the full project route so back/refresh keep the user in the project.
_onProjectOpen(e) {
const { source, label } = e.detail ?? {};
if (!source || !source.startsWith('project-')) return;
const id = source.slice('project-'.length);
if (label) this._projectLabels[id] = label;
location.hash = '#chat/' + source;
}
// Back-out from a project chat: re-point to the main mobile session.
_onProjectExit() {
location.hash = '#chat';
}
// ── Native bridge ──────────────────────────────────────────────────────────
// Web → Native: tell the iOS shell which section/project is active so it can
// highlight the matching native tab. `path` is only set for the file viewer
// (the document being shown). No-op outside WKWebView — the `skaldNav` message
// handler only exists when the shell registered it.
_notifyNative(section, project, path = null) {
try {
window.webkit?.messageHandlers?.skaldNav?.postMessage({ section, project, path });
} catch { /* not in native shell */ }
}
render() {
const s = this._section;
const item = (id, icon, label) => html`
<div class="mobile-nav-item ${s === id ? 'active' : ''}"
@click=${() => this._nav(id)}>
<span class="nav-icon"><i class="bi ${icon}"></i></span>
<span>${label}</span>
</div>
`;
return html`
<div id="mobile-root">
<div class="mobile-content">
<inbox-page
.visible=${s === 'inbox'}
style=${s === 'inbox' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
></inbox-page>
<chat-page
.visible=${s === 'chat'}
.source=${this._chatSource}
.label=${this._chatLabel}
@project-exit=${() => this._onProjectExit()}
style=${s === 'chat' ? 'flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column' : 'display:none'}
></chat-page>
<projects-page
.visible=${s === 'projects'}
@project-open=${(e) => this._onProjectOpen(e)}
style=${s === 'projects' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
></projects-page>
<mobile-file-viewer-page
.visible=${s === 'file_viewer'}
.path=${this._filePath}
style=${s === 'file_viewer' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
></mobile-file-viewer-page>
<mobile-tool-detail-page
.visible=${s === 'tool_detail'}
tool-id=${this._toolId ?? nothing}
style=${s === 'tool_detail' ? 'flex:1;min-height:0;overflow:auto' : 'display:none'}
></mobile-tool-detail-page>
<settings-page
.visible=${s === 'settings'}
style=${s === 'settings' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
></settings-page>
${s === 'notifications' ? html`
<div class="mobile-coming-soon">
<i class="bi bi-bell"></i>
<p>${t('mobile.coming_soon')}</p>
</div>
` : ''}
</div>
${this._native ? nothing : html`
<nav class="mobile-nav">
${item('inbox', 'bi-inbox', t('mobile.nav.inbox'))}
${item('projects', 'bi-folder2-open', t('mobile.nav.projects'))}
${item('chat', 'bi-chat-dots-fill', t('mobile.nav.chat'))}
${item('notifications', 'bi-bell', t('mobile.nav.alerts'))}
${item('settings', 'bi-sliders', t('mobile.nav.settings'))}
</nav>
`}
</div>
`;
}
}
customElements.define('mobile-app', MobileApp);