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.
|
||||
|
||||
**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).
|
||||
|
||||
|
||||
@@ -669,6 +669,17 @@ class CodemanApp {
|
||||
this.maxReconnectAttempts = 10;
|
||||
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).
|
||||
// Every input byte is recorded with a stable clientId + a monotonic
|
||||
// per-session seq, persisted to localStorage, and only dropped once the
|
||||
@@ -1457,6 +1468,10 @@ class CodemanApp {
|
||||
// then ramp up for real network issues.
|
||||
const delay = this.reconnectAttempts <= 1 ? 200
|
||||
: 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);
|
||||
};
|
||||
|
||||
@@ -2333,7 +2348,17 @@ class CodemanApp {
|
||||
|
||||
setConnectionStatus(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._updateConnectionLossUi();
|
||||
if (status === 'connected') {
|
||||
// Reconnected (SSE) — push any durably-queued input out immediately
|
||||
// instead of waiting for the next 2s sweep.
|
||||
@@ -2955,6 +2980,9 @@ class CodemanApp {
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true;
|
||||
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();
|
||||
// Network came back — drain durably-queued input right away.
|
||||
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 */
|
||||
_updateCjkInputState() {
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
|
||||
@@ -180,6 +180,81 @@ function planWsReconnect(code, attempt) {
|
||||
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') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
@@ -190,6 +265,10 @@ if (typeof window !== 'undefined') {
|
||||
window.CodemanWsReconnect = {
|
||||
plan: planWsReconnect,
|
||||
};
|
||||
window.CodemanConnectionLoss = {
|
||||
compute: computeConnectionLossUi,
|
||||
GRACE_MS: CONNECTION_LOSS_GRACE_MS,
|
||||
};
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
|
||||
@@ -149,6 +149,16 @@
|
||||
</div>
|
||||
</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) -->
|
||||
<div class="timer-banner" id="timerBanner" style="display: none;">
|
||||
<div class="timer-content">
|
||||
@@ -2679,6 +2689,39 @@
|
||||
<!-- Lines drawn dynamically -->
|
||||
</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="i18n.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
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. */
|
||||
.webview-modal-actions { justify-content: space-between; }
|
||||
.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