fix: OFFLINE status stuck + WebGL context loss handling
- Fix WebSocket reconnect bug: catch block in connectStatusWs() returned without scheduling a retry, causing permanent OFFLINE on constructor failure. Now retries with exponential backoff. - Change OFFLINE label to "Reconnecting..." — more accurate and friendlier since we always attempt to reconnect automatically. - Add WebGL support detection before Three.js renderer init — shows text-only fallback view with mood/activity for unsupported browsers. - Add mobile detection (coarse pointer + small viewport) — shows the same friendly fallback rather than a broken 3D scene. - Add webglcontextlost / webglcontextrestored handlers — shows "Timmy is taking a quick break" overlay with auto-retry on context loss; halts the animation loop to avoid cascading errors and resumes cleanly on restore. - Keep fallback view in sync with live state (mood/activity from WS). Fixes #811 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -395,7 +395,7 @@
|
||||
if (!dot || !label) return;
|
||||
if (!wsConnected) {
|
||||
dot.className = 'mc-conn-dot red';
|
||||
label.textContent = 'OFFLINE';
|
||||
label.textContent = 'Reconnecting...';
|
||||
} else if (ollamaOk === false) {
|
||||
dot.className = 'mc-conn-dot amber';
|
||||
label.textContent = 'NO LLM';
|
||||
@@ -431,7 +431,12 @@
|
||||
var ws;
|
||||
try {
|
||||
ws = new WebSocket(protocol + '//' + window.location.host + '/swarm/live');
|
||||
} catch(e) { return; }
|
||||
} catch(e) {
|
||||
// WebSocket constructor failed (e.g. invalid environment) — retry
|
||||
setTimeout(connectStatusWs, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = function() {
|
||||
wsConnected = true;
|
||||
|
||||
@@ -103,6 +103,28 @@
|
||||
<div id="submit-job-backdrop" class="submit-job-backdrop"></div>
|
||||
</div>
|
||||
|
||||
<!-- GPU context loss overlay -->
|
||||
<div id="gpu-error-overlay" class="hidden">
|
||||
<div class="gpu-error-icon">🔮</div>
|
||||
<div class="gpu-error-title">Timmy is taking a quick break</div>
|
||||
<div class="gpu-error-msg">
|
||||
The 3D workshop lost its connection to the GPU.<br>
|
||||
Attempting to restore the scene…
|
||||
</div>
|
||||
<div class="gpu-error-retry" id="gpu-retry-msg">Reconnecting automatically…</div>
|
||||
</div>
|
||||
|
||||
<!-- WebGL / mobile fallback -->
|
||||
<div id="webgl-fallback" class="hidden">
|
||||
<div class="fallback-title">Timmy</div>
|
||||
<div class="fallback-subtitle">The Workshop</div>
|
||||
<div class="fallback-status">
|
||||
<div class="mood-line" id="fallback-mood">focused</div>
|
||||
<p id="fallback-detail">Pondering the arcane arts of code…</p>
|
||||
</div>
|
||||
<a href="/" class="fallback-link">Open Mission Control</a>
|
||||
</div>
|
||||
|
||||
<!-- About Panel -->
|
||||
<div id="about-panel" class="about-panel">
|
||||
<div class="about-panel-content">
|
||||
@@ -157,6 +179,38 @@
|
||||
import { StateReader } from "./state.js";
|
||||
import { messageQueue } from "./queue.js";
|
||||
|
||||
// --- Mobile detection: redirect to text interface on small touch devices ---
|
||||
const isMobile = window.matchMedia("(pointer: coarse)").matches
|
||||
&& window.innerWidth < 768;
|
||||
if (isMobile) {
|
||||
const fallback = document.getElementById("webgl-fallback");
|
||||
if (fallback) fallback.classList.remove("hidden");
|
||||
// Don't initialise the 3D scene on mobile
|
||||
throw new Error("Mobile device — 3D scene skipped");
|
||||
}
|
||||
|
||||
// --- WebGL support detection ---
|
||||
function _hasWebGL() {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
return !!(
|
||||
canvas.getContext("webgl2") ||
|
||||
canvas.getContext("webgl") ||
|
||||
canvas.getContext("experimental-webgl")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!_hasWebGL()) {
|
||||
const fallback = document.getElementById("webgl-fallback");
|
||||
const detail = document.getElementById("fallback-detail");
|
||||
if (fallback) fallback.classList.remove("hidden");
|
||||
if (detail) detail.textContent =
|
||||
"Your device doesn\u2019t support WebGL. Use a modern browser to see the 3D workshop.";
|
||||
throw new Error("WebGL not supported");
|
||||
}
|
||||
|
||||
// --- Renderer ---
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
@@ -167,6 +221,26 @@
|
||||
renderer.toneMappingExposure = 0.8;
|
||||
document.body.prepend(renderer.domElement);
|
||||
|
||||
// --- WebGL context loss / restore ---
|
||||
const _gpuOverlay = document.getElementById("gpu-error-overlay");
|
||||
const _gpuRetryMsg = document.getElementById("gpu-retry-msg");
|
||||
let _animating = true;
|
||||
|
||||
renderer.domElement.addEventListener("webglcontextlost", (ev) => {
|
||||
ev.preventDefault();
|
||||
_animating = false;
|
||||
if (_gpuOverlay) _gpuOverlay.classList.remove("hidden");
|
||||
if (_gpuRetryMsg) _gpuRetryMsg.textContent = "Reconnecting automatically\u2026";
|
||||
console.warn("[Workshop] WebGL context lost — waiting for restore");
|
||||
}, false);
|
||||
|
||||
renderer.domElement.addEventListener("webglcontextrestored", () => {
|
||||
_animating = true;
|
||||
if (_gpuOverlay) _gpuOverlay.classList.add("hidden");
|
||||
console.info("[Workshop] WebGL context restored — resuming");
|
||||
animate();
|
||||
}, false);
|
||||
|
||||
// --- Scene ---
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a14);
|
||||
@@ -195,6 +269,13 @@
|
||||
if (moodEl) {
|
||||
moodEl.textContent = state.timmyState.mood;
|
||||
}
|
||||
// Keep fallback view in sync when it's visible
|
||||
const fallbackMood = document.getElementById("fallback-mood");
|
||||
const fallbackDetail = document.getElementById("fallback-detail");
|
||||
if (fallbackMood) fallbackMood.textContent = state.timmyState.mood;
|
||||
if (fallbackDetail && state.timmyState.activity) {
|
||||
fallbackDetail.textContent = state.timmyState.activity + "\u2026";
|
||||
}
|
||||
});
|
||||
|
||||
// Replay queued jobs whenever the server comes back online.
|
||||
@@ -537,6 +618,7 @@
|
||||
const clock = new THREE.Clock();
|
||||
|
||||
function animate() {
|
||||
if (!_animating) return;
|
||||
requestAnimationFrame(animate);
|
||||
const dt = clock.getDelta();
|
||||
|
||||
|
||||
@@ -715,3 +715,116 @@ canvas {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* GPU context loss overlay */
|
||||
#gpu-error-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(10, 10, 20, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
#gpu-error-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gpu-error-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.gpu-error-title {
|
||||
font-size: 20px;
|
||||
color: #daa520;
|
||||
margin-bottom: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.gpu-error-msg {
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
line-height: 1.6;
|
||||
max-width: 400px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.gpu-error-retry {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* WebGL / mobile fallback — text-only mode */
|
||||
#webgl-fallback {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a0a14;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
#webgl-fallback.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fallback-title {
|
||||
font-size: 22px;
|
||||
color: #daa520;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.fallback-subtitle {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fallback-status {
|
||||
font-size: 15px;
|
||||
color: #aaa;
|
||||
line-height: 1.7;
|
||||
max-width: 400px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.fallback-status .mood-line {
|
||||
color: #daa520;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fallback-link {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
border: 1px solid rgba(218, 165, 32, 0.4);
|
||||
border-radius: 6px;
|
||||
color: #daa520;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fallback-link:hover {
|
||||
background: rgba(218, 165, 32, 0.1);
|
||||
border-color: rgba(218, 165, 32, 0.7);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user