mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(web): make a dead connection unmistakable instead of a red dot
Opening Codeman with nothing reachable (phone off the tailnet, VPN down, server stopped) rendered a normal-looking UI: the service worker serves the cached app shell, every /api call fails, and the only tell was an 8px red dot in the header corner. On a phone that reads as "there are no sessions". Two surfaces, chosen by whether there is anything worth looking at: - Full-screen overlay while no server state has loaded this page load. It names the host, lists the three things to check (network, VPN/Tailscale, server), counts down to the next retry, and offers "Retry now" plus "Show cached view" to demote itself to the banner. - Non-blocking banner once state HAS loaded, so a mid-session drop leaves the terminal scrollback readable. A 2.5s grace keeps a COM deploy (SSE is back in ~200ms) from flashing the banner every release; navigator.onLine === false skips the grace, since the device saying "no network" is never a blip. Retry re-arms the terminal WebSocket as well as SSE: planWsReconnect can give up outright, and the SSE backoff caps at 30s, so waiting it out is not always an option. The decision is pure (computeConnectionLossUi in constants.js, unit-tested in a node VM like the WS reconnect policy); app.js only writes the DOM.
This commit is contained in:
@@ -266,7 +266,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey.
|
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey.
|
||||||
|
|
||||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), image popups (3000), local echo overlay (7).
|
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
|
||||||
|
|
||||||
|
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), local echo overlay (7).
|
||||||
|
|
||||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||||
|
|
||||||
|
|||||||
@@ -669,6 +669,17 @@ class CodemanApp {
|
|||||||
this.maxReconnectAttempts = 10;
|
this.maxReconnectAttempts = 10;
|
||||||
this.isOnline = navigator.onLine;
|
this.isOnline = navigator.onLine;
|
||||||
|
|
||||||
|
// Connection-loss UI (banner + full-screen overlay). The decision itself is
|
||||||
|
// pure and lives in constants.js (computeConnectionLossUi); these are just
|
||||||
|
// its inputs. `_connDownSince` is the timestamp the transport LEFT the
|
||||||
|
// connected state, which is what the grace window is measured from.
|
||||||
|
this._connDownSince = null;
|
||||||
|
this._nextSseRetryAt = null; // when the scheduled SSE retry fires (countdown)
|
||||||
|
this._offlineOverlayDismissed = false;
|
||||||
|
this._offlineRetryPending = false; // a user-triggered retry is in flight
|
||||||
|
this._offlineUiTicker = null;
|
||||||
|
this._lastOfflineUiKey = '';
|
||||||
|
|
||||||
// Reliable, durable input delivery (replaces the old best-effort queue).
|
// Reliable, durable input delivery (replaces the old best-effort queue).
|
||||||
// Every input byte is recorded with a stable clientId + a monotonic
|
// Every input byte is recorded with a stable clientId + a monotonic
|
||||||
// per-session seq, persisted to localStorage, and only dropped once the
|
// per-session seq, persisted to localStorage, and only dropped once the
|
||||||
@@ -1457,6 +1468,10 @@ class CodemanApp {
|
|||||||
// then ramp up for real network issues.
|
// then ramp up for real network issues.
|
||||||
const delay = this.reconnectAttempts <= 1 ? 200
|
const delay = this.reconnectAttempts <= 1 ? 200
|
||||||
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
|
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
|
||||||
|
// Feeds the "Retrying in Ns" countdown. With a 30s cap on the backoff, a
|
||||||
|
// silent wait that long is indistinguishable from a hung app.
|
||||||
|
this._nextSseRetryAt = Date.now() + delay;
|
||||||
|
this._updateConnectionLossUi();
|
||||||
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
|
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2333,7 +2348,17 @@ class CodemanApp {
|
|||||||
|
|
||||||
setConnectionStatus(status) {
|
setConnectionStatus(status) {
|
||||||
this._connectionStatus = status;
|
this._connectionStatus = status;
|
||||||
|
// Track when the transport left 'connected'. The connection-loss UI waits
|
||||||
|
// out a deploy-length blip before showing anything (see constants.js).
|
||||||
|
if (status === 'connected') {
|
||||||
|
this._connDownSince = null;
|
||||||
|
this._nextSseRetryAt = null;
|
||||||
|
this._offlineOverlayDismissed = false;
|
||||||
|
} else if (this._connDownSince === null) {
|
||||||
|
this._connDownSince = Date.now();
|
||||||
|
}
|
||||||
this._updateConnectionIndicator();
|
this._updateConnectionIndicator();
|
||||||
|
this._updateConnectionLossUi();
|
||||||
if (status === 'connected') {
|
if (status === 'connected') {
|
||||||
// Reconnected (SSE) — push any durably-queued input out immediately
|
// Reconnected (SSE) — push any durably-queued input out immediately
|
||||||
// instead of waiting for the next 2s sweep.
|
// instead of waiting for the next 2s sweep.
|
||||||
@@ -2955,6 +2980,9 @@ class CodemanApp {
|
|||||||
window.addEventListener('online', () => {
|
window.addEventListener('online', () => {
|
||||||
this.isOnline = true;
|
this.isOnline = true;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
|
// Restart the grace window: the radio just came back, so the next couple
|
||||||
|
// of seconds of "not connected" are expected, not a server problem.
|
||||||
|
this._connDownSince = Date.now();
|
||||||
this.connectSSE();
|
this.connectSSE();
|
||||||
// Network came back — drain durably-queued input right away.
|
// Network came back — drain durably-queued input right away.
|
||||||
this._redeliverSweep();
|
this._redeliverSweep();
|
||||||
@@ -2965,6 +2993,116 @@ class CodemanApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Connection-loss UI ─────────────────────────────────────────────────────
|
||||||
|
// Why this exists: the service worker serves the cached app shell, so opening
|
||||||
|
// Codeman with the server unreachable (phone off the tailnet, VPN down,
|
||||||
|
// server stopped) rendered a normal-looking but empty dashboard whose only
|
||||||
|
// hint was an 8px red dot in the header corner. The decision of what to show
|
||||||
|
// is pure (computeConnectionLossUi in constants.js); this is the writer.
|
||||||
|
|
||||||
|
/** Apply the offline banner / overlay for the current connection state. */
|
||||||
|
_updateConnectionLossUi() {
|
||||||
|
const policy = window.CodemanConnectionLoss;
|
||||||
|
const banner = this.$('offlineBanner');
|
||||||
|
const overlay = this.$('offlineOverlay');
|
||||||
|
if (!policy || !banner || !overlay) return;
|
||||||
|
|
||||||
|
const state = policy.compute({
|
||||||
|
isOnline: this.isOnline,
|
||||||
|
status: this._connectionStatus,
|
||||||
|
// Server state has landed at least once this page load (SSE `init`), so
|
||||||
|
// there is a UI worth keeping visible behind a non-blocking banner.
|
||||||
|
everLoaded: this._initGeneration > 0,
|
||||||
|
downSince: this._connDownSince,
|
||||||
|
now: Date.now(),
|
||||||
|
nextRetryAt: this._nextSseRetryAt,
|
||||||
|
overlayDismissed: this._offlineOverlayDismissed,
|
||||||
|
retryPending: this._offlineRetryPending,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The ticker drives both the countdown and the grace deadline; neither is
|
||||||
|
// event-driven, so it must run whenever the transport is down, including
|
||||||
|
// while the decision is still 'hidden' inside the grace window.
|
||||||
|
if (this._connDownSince === null) this._stopOfflineTicker();
|
||||||
|
else this._startOfflineTicker();
|
||||||
|
|
||||||
|
const retryLabel = this._offlineRetryPending
|
||||||
|
? 'Reconnecting…'
|
||||||
|
: state.retryInSec != null && state.retryInSec > 0
|
||||||
|
? `Retrying in ${state.retryInSec}s`
|
||||||
|
: 'Retrying…';
|
||||||
|
|
||||||
|
// Called every second by the ticker, so skip the DOM writes when the rendered
|
||||||
|
// result is unchanged (same reasoning as _updateConnectionIndicator).
|
||||||
|
const key = `${state.mode}|${state.kind}|${retryLabel}`;
|
||||||
|
if (key === this._lastOfflineUiKey) return;
|
||||||
|
this._lastOfflineUiKey = key;
|
||||||
|
|
||||||
|
banner.hidden = state.mode !== 'banner';
|
||||||
|
overlay.hidden = state.mode !== 'overlay';
|
||||||
|
document.body.classList.toggle('connection-lost', state.mode !== 'hidden');
|
||||||
|
|
||||||
|
if (state.mode === 'banner') {
|
||||||
|
const text = this.$('offlineBannerText');
|
||||||
|
const detail = this.$('offlineBannerDetail');
|
||||||
|
if (text) text.textContent = state.title;
|
||||||
|
if (detail) detail.textContent = retryLabel;
|
||||||
|
} else if (state.mode === 'overlay') {
|
||||||
|
const title = this.$('offlineOverlayTitle');
|
||||||
|
const body = this.$('offlineOverlayBody');
|
||||||
|
const host = this.$('offlineOverlayHost');
|
||||||
|
const status = this.$('offlineOverlayStatus');
|
||||||
|
if (title) title.textContent = state.title;
|
||||||
|
if (body) body.textContent = state.detail;
|
||||||
|
if (host) host.textContent = location.host;
|
||||||
|
if (status) status.textContent = retryLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startOfflineTicker() {
|
||||||
|
if (this._offlineUiTicker) return;
|
||||||
|
this._offlineUiTicker = setInterval(() => this._updateConnectionLossUi(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopOfflineTicker() {
|
||||||
|
if (!this._offlineUiTicker) return;
|
||||||
|
clearInterval(this._offlineUiTicker);
|
||||||
|
this._offlineUiTicker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retry button on the banner/overlay: reconnect now instead of waiting out
|
||||||
|
* the backoff (capped at 30s, and the WS plan can give up entirely). */
|
||||||
|
retryConnection() {
|
||||||
|
this._offlineRetryPending = true;
|
||||||
|
this._nextSseRetryAt = null;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this._clearTimer('sseReconnectTimeout');
|
||||||
|
this.isOnline = navigator.onLine;
|
||||||
|
this._lastOfflineUiKey = '';
|
||||||
|
this._updateConnectionLossUi();
|
||||||
|
this.connectSSE();
|
||||||
|
// The terminal socket does not always come back on its own (planWsReconnect
|
||||||
|
// 'give-up'), so the same button re-arms it.
|
||||||
|
if (this.activeSessionId && this._wsState !== 'connected') {
|
||||||
|
this._wsReconnectAttempts = 0;
|
||||||
|
this._connectWs(this.activeSessionId);
|
||||||
|
}
|
||||||
|
this._clearTimer('_offlineRetryTimer');
|
||||||
|
this._offlineRetryTimer = setTimeout(() => {
|
||||||
|
this._offlineRetryPending = false;
|
||||||
|
this._lastOfflineUiKey = '';
|
||||||
|
this._updateConnectionLossUi();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Show cached view": demote the blocking overlay to the banner for the rest
|
||||||
|
* of this outage, so the cached UI can be inspected offline. */
|
||||||
|
dismissOfflineOverlay() {
|
||||||
|
this._offlineOverlayDismissed = true;
|
||||||
|
this._lastOfflineUiKey = '';
|
||||||
|
this._updateConnectionLossUi();
|
||||||
|
}
|
||||||
|
|
||||||
/** Show/hide the CJK input textarea based on user setting or server override */
|
/** Show/hide the CJK input textarea based on user setting or server override */
|
||||||
_updateCjkInputState() {
|
_updateCjkInputState() {
|
||||||
const cjkEl = document.getElementById('cjkInput');
|
const cjkEl = document.getElementById('cjkInput');
|
||||||
|
|||||||
@@ -180,6 +180,81 @@ function planWsReconnect(code, attempt) {
|
|||||||
return { action: 'reconnect', delayMs };
|
return { action: 'reconnect', delayMs };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection-loss UI policy.
|
||||||
|
//
|
||||||
|
// With the service worker serving the cached app shell, Codeman still *renders*
|
||||||
|
// when the server is unreachable (phone off the tailnet, VPN down, server
|
||||||
|
// stopped): a dashboard with no sessions and an 8px red dot in the header
|
||||||
|
// corner. That reads as "there are no sessions", not "you are not connected".
|
||||||
|
// This decides what the app surfaces instead:
|
||||||
|
//
|
||||||
|
// 'overlay': full-screen "can't reach Codeman". Used while the page has
|
||||||
|
// never loaded server state, where the UI behind it is empty
|
||||||
|
// anyway, so blocking it costs nothing and explains everything.
|
||||||
|
// 'banner': non-blocking bar under the header. Used once state HAS loaded,
|
||||||
|
// so the terminal scrollback stays readable while the link is down.
|
||||||
|
// 'hidden': connected, or still inside the grace window.
|
||||||
|
//
|
||||||
|
// Grace: a COM deploy restarts the server and SSE is back in ~200ms. Shouting
|
||||||
|
// on every deploy trains the user to ignore the warning, so a transport that is
|
||||||
|
// merely *not yet connected* gets CONNECTION_LOSS_GRACE_MS to recover.
|
||||||
|
// `navigator.onLine === false` skips the grace entirely: the device itself is
|
||||||
|
// saying there is no network, which is never a 200ms blip.
|
||||||
|
//
|
||||||
|
// Pure: no DOM, no timers, no side effects. `now` is passed in.
|
||||||
|
const CONNECTION_LOSS_GRACE_MS = 2500;
|
||||||
|
|
||||||
|
function computeConnectionLossUi(input) {
|
||||||
|
const {
|
||||||
|
isOnline = true,
|
||||||
|
status = 'connected',
|
||||||
|
everLoaded = false,
|
||||||
|
downSince = null,
|
||||||
|
now = 0,
|
||||||
|
nextRetryAt = null,
|
||||||
|
overlayDismissed = false,
|
||||||
|
retryPending = false,
|
||||||
|
} = input || {};
|
||||||
|
|
||||||
|
const hidden = { mode: 'hidden', kind: 'connected', title: '', detail: '', retryInSec: null };
|
||||||
|
|
||||||
|
// The browser's own offline flag outranks the transport state: no network
|
||||||
|
// means no reconnect is coming until it returns.
|
||||||
|
const hardOffline = !isOnline || status === 'offline';
|
||||||
|
if (!hardOffline) {
|
||||||
|
if (status === 'connected') return hidden;
|
||||||
|
const downMs = downSince == null ? 0 : Math.max(0, now - downSince);
|
||||||
|
if (downMs < CONNECTION_LOSS_GRACE_MS) return { ...hidden, kind: 'connecting' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismissing the overlay ("show cached view") demotes it to the banner for
|
||||||
|
// the rest of this outage, never back to invisible.
|
||||||
|
const mode = everLoaded || overlayDismissed ? 'banner' : 'overlay';
|
||||||
|
// A retry the user just triggered has no scheduled time; the caller renders
|
||||||
|
// an indeterminate "Retrying…" for null.
|
||||||
|
const retryInSec =
|
||||||
|
retryPending || nextRetryAt == null ? null : Math.max(0, Math.ceil((nextRetryAt - now) / 1000));
|
||||||
|
|
||||||
|
if (hardOffline) {
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
kind: 'offline',
|
||||||
|
title: 'No network connection',
|
||||||
|
detail: 'This device is offline. Codeman is showing the last cached view.',
|
||||||
|
retryInSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
kind: 'unreachable',
|
||||||
|
title: "Can't reach the Codeman server",
|
||||||
|
detail:
|
||||||
|
'This device has a network, but the Codeman server is not answering. ' +
|
||||||
|
'If you reach Codeman over Tailscale or a VPN, check that it is connected.',
|
||||||
|
retryInSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||||
@@ -190,6 +265,10 @@ if (typeof window !== 'undefined') {
|
|||||||
window.CodemanWsReconnect = {
|
window.CodemanWsReconnect = {
|
||||||
plan: planWsReconnect,
|
plan: planWsReconnect,
|
||||||
};
|
};
|
||||||
|
window.CodemanConnectionLoss = {
|
||||||
|
compute: computeConnectionLossUi,
|
||||||
|
GRACE_MS: CONNECTION_LOSS_GRACE_MS,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||||
|
|||||||
@@ -149,6 +149,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Connection-loss banner: shown once session state HAS loaded and the
|
||||||
|
link then drops, so the terminal stays readable behind it.
|
||||||
|
The blocking variant is #offlineOverlay at the end of <body>. -->
|
||||||
|
<div class="offline-banner" id="offlineBanner" role="status" hidden>
|
||||||
|
<span class="offline-banner-dot" aria-hidden="true"></span>
|
||||||
|
<span class="offline-banner-text" id="offlineBannerText">No connection to the Codeman server</span>
|
||||||
|
<span class="offline-banner-detail" id="offlineBannerDetail"></span>
|
||||||
|
<button class="offline-banner-retry" id="offlineBannerRetry" onclick="app.retryConnection()">Retry now</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Timer Banner (shown when timed run is active) -->
|
<!-- Timer Banner (shown when timed run is active) -->
|
||||||
<div class="timer-banner" id="timerBanner" style="display: none;">
|
<div class="timer-banner" id="timerBanner" style="display: none;">
|
||||||
<div class="timer-content">
|
<div class="timer-content">
|
||||||
@@ -2679,6 +2689,39 @@
|
|||||||
<!-- Lines drawn dynamically -->
|
<!-- Lines drawn dynamically -->
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
<!-- Connection-loss overlay: the app shell is served from the service-worker
|
||||||
|
cache, so Codeman renders even with nothing reachable. Without this, that
|
||||||
|
looks like an empty dashboard rather than a dead connection. Only shown
|
||||||
|
while no server state has loaded this page load. -->
|
||||||
|
<div class="offline-overlay" id="offlineOverlay" hidden>
|
||||||
|
<div class="offline-overlay-card" role="alertdialog" aria-labelledby="offlineOverlayTitle" aria-describedby="offlineOverlayBody">
|
||||||
|
<div class="offline-overlay-icon" aria-hidden="true">
|
||||||
|
<svg width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M1 1l22 22"/>
|
||||||
|
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
|
||||||
|
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
|
||||||
|
<path d="M10.71 5.05A16 16 0 0 1 22.58 9"/>
|
||||||
|
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
|
||||||
|
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
||||||
|
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="offline-overlay-title" id="offlineOverlayTitle">Can't reach the Codeman server</h2>
|
||||||
|
<p class="offline-overlay-body" id="offlineOverlayBody"></p>
|
||||||
|
<div class="offline-overlay-host" id="offlineOverlayHost"></div>
|
||||||
|
<ul class="offline-overlay-hints">
|
||||||
|
<li>Check Wi-Fi or mobile data</li>
|
||||||
|
<li>Check your VPN / Tailscale is connected</li>
|
||||||
|
<li>Check the Codeman server is still running</li>
|
||||||
|
</ul>
|
||||||
|
<div class="offline-overlay-actions">
|
||||||
|
<button class="offline-overlay-btn offline-overlay-btn--primary" id="offlineOverlayRetry" onclick="app.retryConnection()">Retry now</button>
|
||||||
|
<button class="offline-overlay-btn" onclick="app.dismissOfflineOverlay()">Show cached view</button>
|
||||||
|
</div>
|
||||||
|
<div class="offline-overlay-status" id="offlineOverlayStatus">Retrying…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script defer src="constants.js"></script>
|
<script defer src="constants.js"></script>
|
||||||
<script defer src="i18n.js"></script>
|
<script defer src="i18n.js"></script>
|
||||||
<script defer src="mobile-handlers.js"></script>
|
<script defer src="mobile-handlers.js"></script>
|
||||||
|
|||||||
@@ -2872,3 +2872,47 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
|||||||
padding: 4px 7px;
|
padding: 4px 7px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Connection loss: phone sizing
|
||||||
|
The banner sits in normal flow directly under the fixed header (the container
|
||||||
|
already reserves that space), so it needs the same safe-area padding as the
|
||||||
|
other banners. The overlay is fixed and handles its own insets.
|
||||||
|
============================================================================ */
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
.offline-banner {
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
padding-left: calc(0.5rem + var(--safe-area-left));
|
||||||
|
padding-right: calc(0.5rem + var(--safe-area-right));
|
||||||
|
font-size: 0.7rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The countdown is the first thing to go when the bar gets tight. The
|
||||||
|
wording plus the Retry button carry the message on their own. */
|
||||||
|
.offline-banner-detail {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner-retry {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-card {
|
||||||
|
padding: 22px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13480,3 +13480,200 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
|||||||
/* Delete sits apart from Cancel/Save so it is not fat-fingered on the way to Save. */
|
/* Delete sits apart from Cancel/Save so it is not fat-fingered on the way to Save. */
|
||||||
.webview-modal-actions { justify-content: space-between; }
|
.webview-modal-actions { justify-content: space-between; }
|
||||||
.webview-modal-actions .btn-danger { margin-right: auto; }
|
.webview-modal-actions .btn-danger { margin-right: auto; }
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
Connection loss: banner + full-screen overlay
|
||||||
|
═══════════════════════════════════════════════════════════════
|
||||||
|
The service worker serves the cached shell, so an unreachable server used to
|
||||||
|
render as an empty-but-normal dashboard with only an 8px red dot in the
|
||||||
|
header. Both surfaces below are deliberately skin-independent (literal
|
||||||
|
colors, not tokens): "you are disconnected" must read identically on every
|
||||||
|
skin, including the light ones. Visibility is driven by the `hidden`
|
||||||
|
attribute, so the display rules need !important to lose to it. */
|
||||||
|
|
||||||
|
.offline-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
background: linear-gradient(90deg, #b91c1c, #991b1b);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 1250;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: offline-banner-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes offline-banner-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.25; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner-detail {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner-retry {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-banner-retry:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Above the mobile fixed header (1200) and modals (1300): this is a blocking
|
||||||
|
"nothing works right now" state, and it only appears before any session
|
||||||
|
state has loaded, so there is no modal underneath to bury. Stays below the
|
||||||
|
image popup layer (3000). */
|
||||||
|
.offline-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
padding-top: calc(20px + var(--safe-area-top));
|
||||||
|
padding-bottom: calc(20px + var(--safe-area-bottom));
|
||||||
|
background: rgba(6, 8, 12, 0.93);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 26px 24px 22px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.45);
|
||||||
|
background: #16181d;
|
||||||
|
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||||
|
color: #f3f6fa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-icon {
|
||||||
|
color: #ef4444;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-body {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #b9c0cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-host {
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8b93a1;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-hints {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #a7aebb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-hints li::before {
|
||||||
|
content: '›';
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #e7ebf2;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-btn--primary {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-btn--primary:hover {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-overlay-status {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8b93a1;
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Connection-loss UI policy.
|
||||||
|
*
|
||||||
|
* `CodemanConnectionLoss.compute(input)` is the pure decision behind the
|
||||||
|
* offline banner and the full-screen "can't reach Codeman" overlay in app.js:
|
||||||
|
* given the browser's online flag, the SSE transport status, whether server
|
||||||
|
* state has ever loaded this page load, and how long the transport has been
|
||||||
|
* down, it returns which surface to show and what it should say.
|
||||||
|
*
|
||||||
|
* The regression it guards: with the service worker serving the cached app
|
||||||
|
* shell, an unreachable server rendered a normal-looking empty dashboard whose
|
||||||
|
* only hint was an 8px red dot in the header corner.
|
||||||
|
*
|
||||||
|
* Loaded in a plain node VM context (no jsdom), mirroring
|
||||||
|
* test/ws-reconnect-plan.test.ts.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
type LossInput = {
|
||||||
|
isOnline?: boolean;
|
||||||
|
status?: 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | 'offline';
|
||||||
|
everLoaded?: boolean;
|
||||||
|
downSince?: number | null;
|
||||||
|
now?: number;
|
||||||
|
nextRetryAt?: number | null;
|
||||||
|
overlayDismissed?: boolean;
|
||||||
|
retryPending?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LossState = {
|
||||||
|
mode: 'hidden' | 'banner' | 'overlay';
|
||||||
|
kind: 'connected' | 'connecting' | 'offline' | 'unreachable';
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
retryInSec: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadPolicy() {
|
||||||
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||||
|
return (
|
||||||
|
context.window as {
|
||||||
|
CodemanConnectionLoss: { compute: (input: LossInput) => LossState; GRACE_MS: number };
|
||||||
|
}
|
||||||
|
).CodemanConnectionLoss;
|
||||||
|
}
|
||||||
|
|
||||||
|
const T0 = 1_000_000;
|
||||||
|
|
||||||
|
describe('connection-loss UI policy', () => {
|
||||||
|
it('shows nothing while the SSE stream is connected', () => {
|
||||||
|
const { compute } = loadPolicy();
|
||||||
|
expect(compute({ isOnline: true, status: 'connected', everLoaded: true, now: T0 }).mode).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays hidden through a deploy-length blip (the grace window)', () => {
|
||||||
|
const { compute, GRACE_MS } = loadPolicy();
|
||||||
|
// A COM deploy restarts the server; SSE is back in ~200ms. Shouting on
|
||||||
|
// every deploy would train the user to ignore the banner.
|
||||||
|
const during = compute({
|
||||||
|
isOnline: true,
|
||||||
|
status: 'reconnecting',
|
||||||
|
everLoaded: true,
|
||||||
|
downSince: T0,
|
||||||
|
now: T0 + GRACE_MS - 1,
|
||||||
|
});
|
||||||
|
expect(during.mode).toBe('hidden');
|
||||||
|
expect(during.kind).toBe('connecting');
|
||||||
|
|
||||||
|
const after = compute({
|
||||||
|
isOnline: true,
|
||||||
|
status: 'reconnecting',
|
||||||
|
everLoaded: true,
|
||||||
|
downSince: T0,
|
||||||
|
now: T0 + GRACE_MS,
|
||||||
|
});
|
||||||
|
expect(after.mode).toBe('banner');
|
||||||
|
expect(after.kind).toBe('unreachable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks with the overlay when no server state ever loaded this page load', () => {
|
||||||
|
const { compute, GRACE_MS } = loadPolicy();
|
||||||
|
// The cold-start case: app shell served from the service-worker cache with
|
||||||
|
// nothing reachable behind it. There is no UI worth preserving.
|
||||||
|
const state = compute({
|
||||||
|
isOnline: true,
|
||||||
|
status: 'reconnecting',
|
||||||
|
everLoaded: false,
|
||||||
|
downSince: T0,
|
||||||
|
now: T0 + GRACE_MS + 5000,
|
||||||
|
});
|
||||||
|
expect(state.mode).toBe('overlay');
|
||||||
|
expect(state.title).toMatch(/reach the Codeman server/i);
|
||||||
|
// The VPN/Tailscale hint is the whole point on a phone off the tailnet.
|
||||||
|
expect(state.detail).toMatch(/Tailscale|VPN/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the non-blocking banner once state has loaded, so the terminal stays readable', () => {
|
||||||
|
const { compute, GRACE_MS } = loadPolicy();
|
||||||
|
const state = compute({
|
||||||
|
isOnline: true,
|
||||||
|
status: 'disconnected',
|
||||||
|
everLoaded: true,
|
||||||
|
downSince: T0,
|
||||||
|
now: T0 + GRACE_MS + 60_000,
|
||||||
|
});
|
||||||
|
expect(state.mode).toBe('banner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the grace window when the device itself reports no network', () => {
|
||||||
|
const { compute } = loadPolicy();
|
||||||
|
// navigator.onLine === false is never a 200ms blip.
|
||||||
|
const viaFlag = compute({ isOnline: false, status: 'connecting', everLoaded: true, downSince: T0, now: T0 });
|
||||||
|
expect(viaFlag.mode).toBe('banner');
|
||||||
|
expect(viaFlag.kind).toBe('offline');
|
||||||
|
expect(viaFlag.title).toMatch(/no network/i);
|
||||||
|
|
||||||
|
const viaStatus = compute({ isOnline: true, status: 'offline', everLoaded: false, downSince: T0, now: T0 });
|
||||||
|
expect(viaStatus.mode).toBe('overlay');
|
||||||
|
expect(viaStatus.kind).toBe('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('demotes the overlay to the banner once dismissed, never back to hidden', () => {
|
||||||
|
const { compute, GRACE_MS } = loadPolicy();
|
||||||
|
const base: LossInput = {
|
||||||
|
isOnline: true,
|
||||||
|
status: 'reconnecting',
|
||||||
|
everLoaded: false,
|
||||||
|
downSince: T0,
|
||||||
|
now: T0 + GRACE_MS + 1000,
|
||||||
|
};
|
||||||
|
expect(compute(base).mode).toBe('overlay');
|
||||||
|
expect(compute({ ...base, overlayDismissed: true }).mode).toBe('banner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts down to the next scheduled retry, floored at zero', () => {
|
||||||
|
const { compute, GRACE_MS } = loadPolicy();
|
||||||
|
const at = (nextRetryAt: number | null, extra: Partial<LossInput> = {}) =>
|
||||||
|
compute({
|
||||||
|
isOnline: true,
|
||||||
|
status: 'reconnecting',
|
||||||
|
everLoaded: true,
|
||||||
|
downSince: T0,
|
||||||
|
now: T0 + GRACE_MS,
|
||||||
|
nextRetryAt,
|
||||||
|
...extra,
|
||||||
|
}).retryInSec;
|
||||||
|
|
||||||
|
expect(at(T0 + GRACE_MS + 4000)).toBe(4);
|
||||||
|
expect(at(T0 + GRACE_MS + 4001)).toBe(5); // rounds up, never shows "0s" while waiting
|
||||||
|
expect(at(T0)).toBe(0); // already overdue
|
||||||
|
expect(at(null)).toBeNull(); // no retry scheduled -> indeterminate label
|
||||||
|
// A user-triggered retry has no scheduled time; the caller renders "Reconnecting…".
|
||||||
|
expect(at(T0 + GRACE_MS + 4000, { retryPending: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a missing downSince as freshly down rather than long-dead', () => {
|
||||||
|
const { compute } = loadPolicy();
|
||||||
|
const state = compute({ isOnline: true, status: 'connecting', everLoaded: false, downSince: null, now: T0 });
|
||||||
|
expect(state.mode).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates an empty input', () => {
|
||||||
|
const { compute } = loadPolicy();
|
||||||
|
expect(compute({}).mode).toBe('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user