[claude] Add offline message queue for Workshop panel (#913) (#1205)
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled

Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
This commit was merged in pull request #1205.
This commit is contained in:
2026-03-23 22:16:27 +00:00
committed by rockachopa
parent b197cf409e
commit a5b95356dd
4 changed files with 310 additions and 7 deletions

View File

@@ -86,6 +86,19 @@
<p>Your task has been added to the queue. Timmy will review it shortly.</p>
<button type="button" id="submit-another-btn" class="btn-primary">Submit Another</button>
</div>
<div id="submit-job-queued" class="submit-job-queued hidden">
<div class="queued-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</div>
<h3>Job Queued</h3>
<p>The server is unreachable right now. Your job has been saved locally and will be submitted automatically when the connection is restored.</p>
<div id="queue-count-display" class="queue-count-display"></div>
<button type="button" id="submit-another-queued-btn" class="btn-primary">Submit Another</button>
</div>
</div>
<div id="submit-job-backdrop" class="submit-job-backdrop"></div>
</div>
@@ -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";

90
static/world/queue.js Normal file
View File

@@ -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)
)
);
},
};

View File

@@ -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;
}
}

View File

@@ -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 {