diff --git a/CLAUDE.md b/CLAUDE.md index 1502b558..bfebced1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/src/web/public/app.js b/src/web/public/app.js index d3d6d34f..b62b814a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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'); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 1db36064..c405037b 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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. diff --git a/src/web/public/index.html b/src/web/public/index.html index adebd440..3ab57e5c 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -149,6 +149,16 @@ + + +