diff --git a/static/world/index.html b/static/world/index.html index 8788afe8..a001bcf1 100644 --- a/static/world/index.html +++ b/static/world/index.html @@ -86,6 +86,19 @@

Your task has been added to the queue. Timmy will review it shortly.

+ +
@@ -142,6 +155,7 @@ import { createFamiliar } from "./familiar.js"; import { setupControls } from "./controls.js"; import { StateReader } from "./state.js"; + import { messageQueue } from "./queue.js"; // --- Renderer --- const renderer = new THREE.WebGLRenderer({ antialias: true }); @@ -182,8 +196,60 @@ moodEl.textContent = state.timmyState.mood; } }); + + // Replay queued jobs whenever the server comes back online. + stateReader.onConnectionChange(async (online) => { + if (!online) return; + const pending = messageQueue.getPending(); + if (pending.length === 0) return; + console.log(`[queue] Online — replaying ${pending.length} queued job(s)`); + for (const item of pending) { + try { + const response = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(item.payload), + }); + if (response.ok) { + messageQueue.markDelivered(item.id); + console.log(`[queue] Delivered queued job ${item.id}`); + } else { + messageQueue.markFailed(item.id); + console.warn(`[queue] Failed to deliver job ${item.id}: ${response.status}`); + } + } catch (err) { + // Still offline — leave as QUEUED, will retry next cycle. + console.warn(`[queue] Replay aborted (still offline): ${err}`); + break; + } + } + messageQueue.prune(); + _updateQueueBadge(); + }); + stateReader.connect(); + // --- Queue badge (top-right indicator for pending jobs) --- + function _updateQueueBadge() { + const count = messageQueue.pendingCount(); + let badge = document.getElementById("queue-badge"); + if (count === 0) { + if (badge) badge.remove(); + return; + } + if (!badge) { + badge = document.createElement("div"); + badge.id = "queue-badge"; + badge.className = "queue-badge"; + badge.title = "Jobs queued offline — will submit on reconnect"; + document.getElementById("overlay").appendChild(badge); + } + badge.textContent = `${count} queued`; + } + // Show badge on load if there are already queued messages. + messageQueue.prune(); + _updateQueueBadge(); + // --- About Panel --- const infoBtn = document.getElementById("info-btn"); const aboutPanel = document.getElementById("about-panel"); @@ -228,6 +294,9 @@ const descWarning = document.getElementById("desc-warning"); const submitJobSuccess = document.getElementById("submit-job-success"); const submitAnotherBtn = document.getElementById("submit-another-btn"); + const submitJobQueued = document.getElementById("submit-job-queued"); + const submitAnotherQueuedBtn = document.getElementById("submit-another-queued-btn"); + const queueCountDisplay = document.getElementById("queue-count-display"); // Constants const MAX_TITLE_LENGTH = 200; @@ -255,6 +324,7 @@ submitJobForm.reset(); submitJobForm.classList.remove("hidden"); submitJobSuccess.classList.add("hidden"); + submitJobQueued.classList.add("hidden"); updateCharCounts(); clearErrors(); validateForm(); @@ -363,6 +433,7 @@ submitJobBackdrop.addEventListener("click", closeSubmitJobModal); cancelJobBtn.addEventListener("click", closeSubmitJobModal); submitAnotherBtn.addEventListener("click", resetForm); + submitAnotherQueuedBtn.addEventListener("click", resetForm); // Input event listeners for real-time validation jobTitle.addEventListener("input", () => { @@ -420,9 +491,10 @@ headers: { "Content-Type": "application/json", }, - body: JSON.stringify(formData) + body: JSON.stringify(formData), + signal: AbortSignal.timeout(8000), }); - + if (response.ok) { // Show success state submitJobForm.classList.add("hidden"); @@ -433,9 +505,14 @@ descError.classList.add("visible"); } } catch (error) { - // For demo/development, show success even if API fails + // Server unreachable — persist to localStorage queue. + messageQueue.enqueue(formData); + const count = messageQueue.pendingCount(); submitJobForm.classList.add("hidden"); - submitJobSuccess.classList.remove("hidden"); + submitJobQueued.classList.remove("hidden"); + queueCountDisplay.textContent = + count > 1 ? `${count} jobs queued` : "1 job queued"; + _updateQueueBadge(); } finally { submitJobSubmit.disabled = false; submitJobSubmit.textContent = "Submit Job"; diff --git a/static/world/queue.js b/static/world/queue.js new file mode 100644 index 00000000..d2285bd1 --- /dev/null +++ b/static/world/queue.js @@ -0,0 +1,90 @@ +/** + * Offline message queue for Workshop panel. + * + * Persists undelivered job submissions to localStorage so they survive + * page refreshes and are replayed when the server comes back online. + */ + +const _QUEUE_KEY = "timmy_workshop_queue"; +const _MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours — auto-expire old items + +export const STATUS = { + QUEUED: "queued", + DELIVERED: "delivered", + FAILED: "failed", +}; + +function _load() { + try { + const raw = localStorage.getItem(_QUEUE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function _save(items) { + try { + localStorage.setItem(_QUEUE_KEY, JSON.stringify(items)); + } catch { + /* localStorage unavailable — degrade silently */ + } +} + +function _uid() { + return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** LocalStorage-backed message queue for Workshop job submissions. */ +export const messageQueue = { + /** Add a payload. Returns the created item (with id and status). */ + enqueue(payload) { + const item = { + id: _uid(), + payload, + queuedAt: new Date().toISOString(), + status: STATUS.QUEUED, + }; + const items = _load(); + items.push(item); + _save(items); + return item; + }, + + /** Mark a message as delivered and remove it from storage. */ + markDelivered(id) { + _save(_load().filter((i) => i.id !== id)); + }, + + /** Mark a message as permanently failed (kept for 24h for visibility). */ + markFailed(id) { + _save( + _load().map((i) => + i.id === id ? { ...i, status: STATUS.FAILED } : i + ) + ); + }, + + /** All messages waiting to be delivered. */ + getPending() { + return _load().filter((i) => i.status === STATUS.QUEUED); + }, + + /** Total queued (QUEUED status only) count. */ + pendingCount() { + return this.getPending().length; + }, + + /** Drop expired failed items (> 24h old). */ + prune() { + const cutoff = Date.now() - _MAX_AGE_MS; + _save( + _load().filter( + (i) => + i.status === STATUS.QUEUED || + (i.status === STATUS.FAILED && + new Date(i.queuedAt).getTime() > cutoff) + ) + ); + }, +}; diff --git a/static/world/state.js b/static/world/state.js index a24e6ad4..6eec26aa 100644 --- a/static/world/state.js +++ b/static/world/state.js @@ -3,6 +3,10 @@ * * Provides Timmy's current state to the scene. In Phase 2 this is a * static default; the WebSocket path is stubbed for future use. + * + * Also manages connection health monitoring: pings /api/matrix/health + * every 30 seconds and notifies listeners when online/offline state + * changes so the Workshop can replay any queued messages. */ const DEFAULTS = { @@ -20,11 +24,19 @@ const DEFAULTS = { version: 1, }; +const _HEALTH_URL = "/api/matrix/health"; +const _PING_INTERVAL_MS = 30_000; +const _WS_RECONNECT_DELAY_MS = 5_000; + export class StateReader { constructor() { this.state = { ...DEFAULTS }; this.listeners = []; + this.connectionListeners = []; this._ws = null; + this._online = false; + this._pingTimer = null; + this._reconnectTimer = null; } /** Subscribe to state changes. */ @@ -32,7 +44,12 @@ export class StateReader { this.listeners.push(fn); } - /** Notify all listeners. */ + /** Subscribe to online/offline transitions. Called with (isOnline: bool). */ + onConnectionChange(fn) { + this.connectionListeners.push(fn); + } + + /** Notify all state listeners. */ _notify() { for (const fn of this.listeners) { try { @@ -43,8 +60,48 @@ export class StateReader { } } - /** Try to connect to the world WebSocket for live updates. */ - connect() { + /** Fire connection listeners only when state actually changes. */ + _notifyConnection(online) { + if (online === this._online) return; + this._online = online; + for (const fn of this.connectionListeners) { + try { + fn(online); + } catch (e) { + console.warn("Connection listener error:", e); + } + } + } + + /** Ping the health endpoint once and update connection state. */ + async _ping() { + try { + const r = await fetch(_HEALTH_URL, { + signal: AbortSignal.timeout(5000), + }); + this._notifyConnection(r.ok); + } catch { + this._notifyConnection(false); + } + } + + /** Start 30-second health-check loop (idempotent). */ + _startHealthCheck() { + if (this._pingTimer) return; + this._pingTimer = setInterval(() => this._ping(), _PING_INTERVAL_MS); + } + + /** Schedule a WebSocket reconnect attempt after a delay (idempotent). */ + _scheduleReconnect() { + if (this._reconnectTimer) return; + this._reconnectTimer = setTimeout(() => { + this._reconnectTimer = null; + this._connectWS(); + }, _WS_RECONNECT_DELAY_MS); + } + + /** Open (or re-open) the WebSocket connection. */ + _connectWS() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const url = `${proto}//${location.host}/api/world/ws`; try { @@ -52,10 +109,13 @@ export class StateReader { this._ws.onopen = () => { const dot = document.getElementById("connection-dot"); if (dot) dot.classList.add("connected"); + this._notifyConnection(true); }; this._ws.onclose = () => { const dot = document.getElementById("connection-dot"); if (dot) dot.classList.remove("connected"); + this._notifyConnection(false); + this._scheduleReconnect(); }; this._ws.onmessage = (ev) => { try { @@ -75,9 +135,18 @@ export class StateReader { }; } catch (e) { console.warn("WebSocket unavailable — using static state"); + this._scheduleReconnect(); } } + /** Connect to the world WebSocket and start health-check polling. */ + connect() { + this._connectWS(); + this._startHealthCheck(); + // Immediate ping so connection status is known before the first interval. + this._ping(); + } + /** Current mood string. */ get mood() { return this.state.timmyState.mood; @@ -92,4 +161,9 @@ export class StateReader { get energy() { return this.state.timmyState.energy; } + + /** Whether the server is currently reachable. */ + get isOnline() { + return this._online; + } } diff --git a/static/world/style.css b/static/world/style.css index 914c355e..a9d85431 100644 --- a/static/world/style.css +++ b/static/world/style.css @@ -604,6 +604,68 @@ canvas { opacity: 1; } +/* Queued State (offline buffer) */ +.submit-job-queued { + text-align: center; + padding: 32px 16px; +} + +.submit-job-queued.hidden { + display: none; +} + +.queued-icon { + width: 64px; + height: 64px; + margin: 0 auto 20px; + color: #ffaa33; +} + +.queued-icon svg { + width: 100%; + height: 100%; +} + +.submit-job-queued h3 { + font-size: 20px; + color: #ffaa33; + margin: 0 0 12px 0; +} + +.submit-job-queued p { + font-size: 14px; + color: #888; + margin: 0 0 16px 0; + line-height: 1.5; +} + +.queue-count-display { + font-size: 12px; + color: #ffaa33; + margin-bottom: 24px; + opacity: 0.8; +} + +/* Queue badge — shown in overlay corner when offline jobs are pending */ +.queue-badge { + position: absolute; + bottom: 16px; + right: 16px; + padding: 4px 10px; + background: rgba(10, 10, 20, 0.85); + border: 1px solid rgba(255, 170, 51, 0.6); + border-radius: 12px; + color: #ffaa33; + font-size: 11px; + pointer-events: none; + animation: queue-pulse 2s ease-in-out infinite; +} + +@keyframes queue-pulse { + 0%, 100% { opacity: 0.8; } + 50% { opacity: 1; } +} + /* Mobile adjustments */ @media (max-width: 480px) { .about-panel-content {