mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-09 19:27:58 +02:00
fix(plan-canvas): deliver browser chat to the agent every time (#2739)
Feedback sent from the canvas only reached an agent through a live /api/await long poll. When a turn ended with no await parked, queueFeedback wrote the message to sessions.json and nothing ever consumed it, so sending appeared to do nothing at all. The presence pill made it worse: workingKeys had no expiry and the feedback handler never broadcast presence, so it froze on "agent working" while nobody was listening. Delivery: - Add the stop:plan-canvas-pending hook. It drains undelivered feedback and blocks the Stop, handing the messages to the agent, so a canvas message lands even when no await is running. Scoped to sessions under cwd so parallel agents cannot swallow each other's feedback; set ECC_PLAN_CANVAS_STOP_SCOPE=all to widen. Honors stop_hook_active and fails open on every error path. - run-with-flags.js did not await a hook's run(), so any async hook silently degraded to pass-through. Fixed; plan-canvas-pending is the only async hook today. Presence and indicators: - Presence is now ended/typing/thinking/listening/queued/waiting. thinking and typing self-expire (90s/30s) and a 5s sweep pushes the decay to an idle browser, so the pill can no longer stick. - Broadcast presence when feedback is queued, and clear the activity state when an agent reply lands. - Add POST /api/session/:key/typing so agents can drive the indicator. - Chat shows an animated dots bubble for thinking and typing, plus an explicit note when a message is queued with nobody listening. Respects prefers-reduced-motion. - Send status reports what actually happened instead of always claiming the agent will pick it up. CLI and skill: - Add `ecc-plan-canvas pending` and `typing <file> --state ...`. - SKILL.md documents background await as the primary pattern and makes replying in the canvas mandatory. Tests: 6 new server cases covering queued presence, the typing endpoint, state expiry and the sweep, plus a new hook suite covering delivery, drain-once, stop_hook_active, cwd scoping and fail-open. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,14 @@ const DEFAULT_PORT = 4517;
|
||||
const DEFAULT_HOST = '127.0.0.1';
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
// How long the "agent is thinking" indicator survives without the agent
|
||||
// checking back in, before presence decays to the honest queued/waiting.
|
||||
const DEFAULT_THINKING_STALE_MS = 90 * 1000;
|
||||
// An explicit typing signal expires faster: it means "a reply is seconds away".
|
||||
const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
|
||||
// Presence is push-based, so expiring states need a tick to re-broadcast on.
|
||||
const DEFAULT_PRESENCE_SWEEP_MS = 5 * 1000;
|
||||
const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
|
||||
|
||||
const CONTENT_TYPES = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
@@ -109,6 +117,9 @@ function createPlanCanvasServer({
|
||||
version = '0.0.0',
|
||||
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
||||
heartbeatMs = 15000,
|
||||
thinkingStaleMs = DEFAULT_THINKING_STALE_MS,
|
||||
typingExpiryMs = DEFAULT_TYPING_EXPIRY_MS,
|
||||
presenceSweepMs = DEFAULT_PRESENCE_SWEEP_MS,
|
||||
onIdleShutdown = null,
|
||||
log = () => {}
|
||||
} = {}) {
|
||||
@@ -119,18 +130,40 @@ function createPlanCanvasServer({
|
||||
wake.setMaxListeners(0);
|
||||
const sseClients = new Map(); // key -> Set<res>
|
||||
const awaitCounts = new Map(); // key -> active long-poll count
|
||||
const workingKeys = new Set(); // keys whose agent took feedback and is off working
|
||||
const workingKeys = new Map(); // key -> ms timestamp the agent took feedback
|
||||
const typingKeys = new Map(); // key -> ms timestamp the agent signalled composing
|
||||
const watchers = new Map(); // key -> fs.FSWatcher
|
||||
const lastPresence = new Map(); // key -> last broadcast state, for sweep diffing
|
||||
let idleTimer = null;
|
||||
let presenceSweep = null;
|
||||
let closed = false;
|
||||
|
||||
// --- presence + SSE ---------------------------------------------------
|
||||
|
||||
function presenceFor(key) {
|
||||
/**
|
||||
* Presence never claims more than the server actually knows:
|
||||
*
|
||||
* ended session is closed
|
||||
* typing agent signalled it is composing a reply (self-expiring)
|
||||
* thinking agent took the feedback and is working on it (self-expiring)
|
||||
* listening an `await` long poll is parked on this session right now
|
||||
* queued feedback is sitting undelivered with nobody listening
|
||||
* waiting nothing queued, nobody listening
|
||||
*
|
||||
* `thinking` and `typing` expire on their own so a crashed or distracted
|
||||
* agent decays to an honest `queued`/`waiting` instead of spinning forever.
|
||||
* The old `working` pill had no expiry and no re-broadcast, so it stuck at
|
||||
* "agent working" while nothing at all was listening.
|
||||
*/
|
||||
function presenceFor(key, now = Date.now()) {
|
||||
const session = store.get(key);
|
||||
if (!session || session.status === 'ended') return 'ended';
|
||||
const typingAt = typingKeys.get(key);
|
||||
if (typingAt !== undefined && now - typingAt < typingExpiryMs) return 'typing';
|
||||
const workingAt = workingKeys.get(key);
|
||||
if (workingAt !== undefined && now - workingAt < thinkingStaleMs) return 'thinking';
|
||||
if ((awaitCounts.get(key) || 0) > 0) return 'listening';
|
||||
return workingKeys.has(key) ? 'working' : 'waiting';
|
||||
return session.pendingFeedback && session.pendingFeedback.length > 0 ? 'queued' : 'waiting';
|
||||
}
|
||||
|
||||
function broadcast(key, event, payload) {
|
||||
@@ -141,7 +174,36 @@ function createPlanCanvasServer({
|
||||
}
|
||||
|
||||
function broadcastPresence(key) {
|
||||
broadcast(key, 'presence', { state: presenceFor(key) });
|
||||
const state = presenceFor(key);
|
||||
lastPresence.set(key, state);
|
||||
broadcast(key, 'presence', { state });
|
||||
}
|
||||
|
||||
// Re-broadcast only where an expiry actually changed the answer, so an
|
||||
// untouched canvas sees the thinking bubble clear itself.
|
||||
function sweepPresence() {
|
||||
for (const key of sseClients.keys()) {
|
||||
const state = presenceFor(key);
|
||||
if (lastPresence.get(key) !== state) broadcastPresence(key);
|
||||
}
|
||||
}
|
||||
|
||||
function startPresenceSweep() {
|
||||
if (presenceSweep || !presenceSweepMs) return;
|
||||
presenceSweep = setInterval(sweepPresence, presenceSweepMs);
|
||||
if (presenceSweep.unref) presenceSweep.unref();
|
||||
}
|
||||
|
||||
// The agent is off working on this feedback batch; start the thinking clock.
|
||||
function markThinking(key) {
|
||||
workingKeys.set(key, Date.now());
|
||||
typingKeys.delete(key);
|
||||
}
|
||||
|
||||
// A reply landed (or the agent picked the session back up): stop pretending.
|
||||
function clearAgentActivity(key) {
|
||||
workingKeys.delete(key);
|
||||
typingKeys.delete(key);
|
||||
}
|
||||
|
||||
function connectionCount() {
|
||||
@@ -205,6 +267,7 @@ function createPlanCanvasServer({
|
||||
function endSession(key, endedBy) {
|
||||
const session = store.end(key, endedBy);
|
||||
if (!session) return null;
|
||||
clearAgentActivity(key);
|
||||
wake.emit(`wake:${key}`);
|
||||
broadcast(key, 'ended', { endedBy: session.endedBy });
|
||||
broadcastPresence(key);
|
||||
@@ -260,7 +323,7 @@ function createPlanCanvasServer({
|
||||
|
||||
const first = store.takeFeedback(key);
|
||||
if (first.status !== 'waiting') {
|
||||
if (first.status === 'feedback') workingKeys.add(key);
|
||||
if (first.status === 'feedback') markThinking(key);
|
||||
broadcastPresence(key);
|
||||
return sendJson(res, 200, first);
|
||||
}
|
||||
@@ -268,7 +331,7 @@ function createPlanCanvasServer({
|
||||
// Long poll: hold the request open until feedback or session end.
|
||||
noteConnectionOpened();
|
||||
awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1);
|
||||
workingKeys.delete(key);
|
||||
clearAgentActivity(key);
|
||||
broadcastPresence(key);
|
||||
|
||||
let settled = false;
|
||||
@@ -279,7 +342,7 @@ function createPlanCanvasServer({
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (payload) {
|
||||
if (payload.status === 'feedback') workingKeys.add(key);
|
||||
if (payload.status === 'feedback') markThinking(key);
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
broadcastPresence(key);
|
||||
@@ -328,7 +391,7 @@ function createPlanCanvasServer({
|
||||
return sendJson(res, 200, { status: 'ended', endedBy: 'agent' });
|
||||
}
|
||||
|
||||
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply)$/);
|
||||
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply|typing)$/);
|
||||
if (sessionMatch && req.method === 'POST') {
|
||||
const [, key, action] = sessionMatch;
|
||||
const session = store.get(key);
|
||||
@@ -341,7 +404,17 @@ function createPlanCanvasServer({
|
||||
wake.emit(`wake:${key}`);
|
||||
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
|
||||
if (body.endSession) broadcast(key, 'ended', { endedBy: 'user' });
|
||||
return sendJson(res, 200, { status: 'queued', accepted: result.accepted.length, pending: result.pending });
|
||||
// A parked `await` takes the batch synchronously on the wake above, so
|
||||
// presence is already `thinking` by now; with nobody listening it
|
||||
// reports `queued`. Either way the browser must be told, which the
|
||||
// original handler never did, leaving a stale pill on screen.
|
||||
broadcastPresence(key);
|
||||
return sendJson(res, 200, {
|
||||
status: 'queued',
|
||||
accepted: result.accepted.length,
|
||||
pending: result.pending,
|
||||
presence: presenceFor(key)
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'end') {
|
||||
@@ -355,9 +428,26 @@ function createPlanCanvasServer({
|
||||
return sendJson(res, 400, { error: 'text is required' });
|
||||
}
|
||||
const entry = store.addAgentReply(key, body.text);
|
||||
clearAgentActivity(key);
|
||||
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
|
||||
broadcastPresence(key);
|
||||
return sendJson(res, 200, { status: 'sent', at: entry.at });
|
||||
}
|
||||
|
||||
// Agents drive the chat indicator explicitly: `thinking` while they work,
|
||||
// `typing` right before a reply lands, `idle` to take the bubble down.
|
||||
if (action === 'typing') {
|
||||
const body = await readJsonBody(req);
|
||||
const state = typeof body.state === 'string' ? body.state : 'typing';
|
||||
if (!TYPING_STATES.has(state)) {
|
||||
return sendJson(res, 400, { error: `state must be one of: ${[...TYPING_STATES].join(', ')}` });
|
||||
}
|
||||
if (state === 'idle') clearAgentActivity(key);
|
||||
else if (state === 'typing') typingKeys.set(key, Date.now());
|
||||
else markThinking(key);
|
||||
broadcastPresence(key);
|
||||
return sendJson(res, 200, { status: 'ok', presence: presenceFor(key) });
|
||||
}
|
||||
}
|
||||
|
||||
return sendJson(res, 404, { error: 'not found' });
|
||||
@@ -376,6 +466,8 @@ function createPlanCanvasServer({
|
||||
res.write(`event: presence\ndata: ${JSON.stringify({ state: presenceFor(key) })}\n\n`);
|
||||
if (!sseClients.has(key)) sseClients.set(key, new Set());
|
||||
sseClients.get(key).add(res);
|
||||
lastPresence.set(key, presenceFor(key));
|
||||
startPresenceSweep();
|
||||
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
|
||||
if (ping.unref) ping.unref();
|
||||
req.on('close', () => {
|
||||
@@ -383,7 +475,10 @@ function createPlanCanvasServer({
|
||||
const clients = sseClients.get(key);
|
||||
if (clients) {
|
||||
clients.delete(res);
|
||||
if (clients.size === 0) sseClients.delete(key);
|
||||
if (clients.size === 0) {
|
||||
sseClients.delete(key);
|
||||
lastPresence.delete(key);
|
||||
}
|
||||
}
|
||||
noteConnectionClosed();
|
||||
});
|
||||
@@ -499,6 +594,9 @@ function createPlanCanvasServer({
|
||||
function close() {
|
||||
closed = true;
|
||||
clearTimeout(idleTimer);
|
||||
clearInterval(presenceSweep);
|
||||
presenceSweep = null;
|
||||
lastPresence.clear();
|
||||
for (const key of watchers.keys()) unwatchSession(key);
|
||||
for (const clients of sseClients.values()) {
|
||||
for (const client of clients) client.end();
|
||||
@@ -522,12 +620,14 @@ function createPlanCanvasServer({
|
||||
});
|
||||
}
|
||||
|
||||
return { server, listen, close, presenceFor, watchSession };
|
||||
return { server, listen, close, presenceFor, sweepPresence, watchSession };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_THINKING_STALE_MS,
|
||||
DEFAULT_TYPING_EXPIRY_MS,
|
||||
createPlanCanvasServer,
|
||||
resolveIdleTimeoutMs,
|
||||
resolvePort
|
||||
|
||||
@@ -103,7 +103,8 @@ function canvasCss() {
|
||||
.presence{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:500;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:99px;padding:3px 10px 3px 8px;white-space:nowrap}
|
||||
.presence .dot{width:7px;height:7px;border-radius:99px;background:var(--text3)}
|
||||
.presence[data-state="listening"] .dot{background:var(--green);box-shadow:0 0 0 3px var(--green-glow);animation:pulse 2s infinite}
|
||||
.presence[data-state="working"] .dot{background:var(--orange);box-shadow:0 0 0 3px var(--orange-glow)}
|
||||
.presence[data-state="thinking"] .dot,.presence[data-state="typing"] .dot{background:var(--accent);box-shadow:0 0 0 3px var(--accent-glow);animation:pulse 1.2s infinite}
|
||||
.presence[data-state="queued"] .dot{background:var(--orange);box-shadow:0 0 0 3px var(--orange-glow)}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
|
||||
|
||||
.toggle{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--text2);cursor:pointer;user-select:none}
|
||||
@@ -140,6 +141,23 @@ function canvasCss() {
|
||||
.msg.kind-verdict{border-left:2px solid var(--green)}
|
||||
.chat .empty{color:var(--text3);font-size:12px;text-align:center;margin-top:24px;line-height:1.6}
|
||||
|
||||
/* iMessage-style activity bubble: dots while the agent thinks or types. */
|
||||
.typing{align-self:flex-start;display:none;align-items:center;gap:8px;background:var(--bg3);border:1px solid var(--border);border-bottom-left-radius:3px;border-radius:10px;padding:9px 12px}
|
||||
.typing.show{display:flex}
|
||||
.typing .dots{display:flex;align-items:center;gap:3px}
|
||||
.typing .dots i{width:6px;height:6px;border-radius:99px;background:var(--text2);animation:typing-bounce 1.4s infinite ease-in-out both}
|
||||
.typing .dots i:nth-child(1){animation-delay:-.32s}
|
||||
.typing .dots i:nth-child(2){animation-delay:-.16s}
|
||||
.typing .label{font-size:11px;color:var(--text3)}
|
||||
@keyframes typing-bounce{0%,80%,100%{transform:translateY(0);opacity:.4}40%{transform:translateY(-4px);opacity:1}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.typing .dots i{animation:none;opacity:.7}
|
||||
.presence .dot{animation:none}
|
||||
}
|
||||
/* A queued message nobody is listening for gets an explicit, honest note. */
|
||||
.stalled{align-self:flex-start;display:none;gap:8px;background:var(--orange-glow);border:1px solid color-mix(in srgb,var(--orange) 35%,transparent);border-radius:10px;padding:8px 11px;font-size:11.5px;color:var(--text2);line-height:1.5}
|
||||
.stalled.show{display:flex}
|
||||
|
||||
.queue{padding:8px 14px 0;display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto}
|
||||
.pill{display:flex;align-items:flex-start;gap:8px;background:var(--bg3);border:1px solid var(--border);border-left:2px solid var(--teal);border-radius:6px;padding:6px 8px;font-size:11.5px}
|
||||
.pill.kind-chat{border-left-color:var(--accent)}
|
||||
@@ -267,27 +285,69 @@ function canvasClientJs() {
|
||||
}
|
||||
renderQueue();
|
||||
|
||||
// --- activity indicators ---------------------------------------------
|
||||
// Built once and re-appended on every chat render so the animation never
|
||||
// restarts mid-thought.
|
||||
const typingEl = document.createElement('div');
|
||||
typingEl.className = 'typing';
|
||||
typingEl.setAttribute('role', 'status');
|
||||
typingEl.setAttribute('aria-live', 'polite');
|
||||
const dots = document.createElement('span');
|
||||
dots.className = 'dots';
|
||||
dots.append(document.createElement('i'), document.createElement('i'), document.createElement('i'));
|
||||
const typingLabel = document.createElement('span');
|
||||
typingLabel.className = 'label';
|
||||
typingEl.append(dots, typingLabel);
|
||||
|
||||
const stalledEl = document.createElement('div');
|
||||
stalledEl.className = 'stalled';
|
||||
stalledEl.setAttribute('role', 'status');
|
||||
|
||||
const TYPING_LABELS = { thinking: 'agent is thinking\\u2026', typing: 'agent is typing\\u2026' };
|
||||
|
||||
function renderActivity(state) {
|
||||
const typingText = TYPING_LABELS[state];
|
||||
typingEl.classList.toggle('show', Boolean(typingText));
|
||||
if (typingText) typingLabel.textContent = typingText;
|
||||
const stalled = state === 'queued';
|
||||
stalledEl.classList.toggle('show', stalled);
|
||||
if (stalled) {
|
||||
stalledEl.textContent =
|
||||
'Delivered to the queue. Your agent is not listening right now, so it picks this up the moment it checks in.';
|
||||
}
|
||||
if (typingText || stalled) scrollToEnd();
|
||||
}
|
||||
|
||||
// --- chat -----------------------------------------------------------
|
||||
function atBottom() {
|
||||
return chatLog.scrollHeight - chatLog.scrollTop - chatLog.clientHeight < 40;
|
||||
}
|
||||
function scrollToEnd() { chatLog.scrollTop = chatLog.scrollHeight; }
|
||||
|
||||
function renderChat(entries) {
|
||||
const pinned = atBottom();
|
||||
chatLog.innerHTML = '';
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = 'Click anything in the plan to annotate it, or type below. Feedback goes straight to your agent.';
|
||||
chatLog.appendChild(empty);
|
||||
return;
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + (entry.role === 'agent' ? 'agent' : 'user') + ' kind-' + (entry.kind || 'chat');
|
||||
div.textContent = entry.text;
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'meta';
|
||||
meta.textContent = (entry.role === 'agent' ? 'agent' : 'you') + ' \\u00B7 ' + new Date(entry.at).toLocaleTimeString();
|
||||
div.appendChild(meta);
|
||||
chatLog.appendChild(div);
|
||||
}
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + (entry.role === 'agent' ? 'agent' : 'user') + ' kind-' + (entry.kind || 'chat');
|
||||
div.textContent = entry.text;
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'meta';
|
||||
meta.textContent = (entry.role === 'agent' ? 'agent' : 'you') + ' \\u00B7 ' + new Date(entry.at).toLocaleTimeString();
|
||||
div.appendChild(meta);
|
||||
chatLog.appendChild(div);
|
||||
}
|
||||
chatLog.scrollTop = chatLog.scrollHeight;
|
||||
// The indicators live at the tail of the log, so they survive re-render.
|
||||
chatLog.appendChild(typingEl);
|
||||
chatLog.appendChild(stalledEl);
|
||||
if (pinned) scrollToEnd();
|
||||
}
|
||||
renderChat(boot.chat || []);
|
||||
|
||||
@@ -312,11 +372,17 @@ function canvasClientJs() {
|
||||
body: JSON.stringify({ items })
|
||||
});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
queue = [];
|
||||
persistQueue();
|
||||
renderQueue();
|
||||
input.value = '';
|
||||
statusEl.textContent = 'Sent. Your agent picks this up on its next check-in.';
|
||||
// Say what actually happened: a parked agent takes the batch on the
|
||||
// spot, otherwise it sits in the queue until the agent checks in.
|
||||
statusEl.textContent = body.presence === 'thinking' || body.presence === 'typing'
|
||||
? 'Delivered. Your agent has it.'
|
||||
: 'Queued. Your agent picks this up the moment it checks in.';
|
||||
if (body.presence) applyPresence(body.presence);
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'Send failed (' + err.message + ') - is the canvas server still running?';
|
||||
} finally {
|
||||
@@ -345,6 +411,7 @@ function canvasClientJs() {
|
||||
ended = true;
|
||||
sendBtn.disabled = true;
|
||||
input.disabled = true;
|
||||
renderActivity('ended');
|
||||
presence.setAttribute('data-state', 'ended');
|
||||
presence.querySelector('.label').textContent = 'session ended';
|
||||
$('endedOverlay').classList.add('show');
|
||||
@@ -355,20 +422,28 @@ function canvasClientJs() {
|
||||
if (ended) markEnded(boot.endedBy);
|
||||
|
||||
// --- server events ----------------------------------------------------
|
||||
const PRESENCE_LABELS = { waiting: 'agent not connected', listening: 'agent listening', working: 'agent working\\u2026' };
|
||||
const PRESENCE_LABELS = {
|
||||
waiting: 'agent not connected',
|
||||
listening: 'agent listening',
|
||||
thinking: 'agent is thinking\\u2026',
|
||||
typing: 'agent is typing\\u2026',
|
||||
queued: 'queued for your agent'
|
||||
};
|
||||
function applyPresence(state) {
|
||||
if (ended) return;
|
||||
presence.setAttribute('data-state', state);
|
||||
presence.querySelector('.label').textContent = PRESENCE_LABELS[state] || state;
|
||||
renderActivity(state);
|
||||
}
|
||||
function connectEvents() {
|
||||
const es = new EventSource('/events/' + key);
|
||||
es.addEventListener('chat-sync', e => renderChat(JSON.parse(e.data).chat || []));
|
||||
es.addEventListener('presence', e => {
|
||||
const state = JSON.parse(e.data).state;
|
||||
if (ended) return;
|
||||
presence.setAttribute('data-state', state);
|
||||
presence.querySelector('.label').textContent = PRESENCE_LABELS[state] || state;
|
||||
});
|
||||
es.addEventListener('presence', e => applyPresence(JSON.parse(e.data).state));
|
||||
es.addEventListener('reload', reloadArtifact);
|
||||
es.addEventListener('ended', e => { markEnded(JSON.parse(e.data).endedBy); es.close(); });
|
||||
es.onerror = () => {
|
||||
if (ended) return;
|
||||
renderActivity('offline');
|
||||
presence.setAttribute('data-state', 'waiting');
|
||||
presence.querySelector('.label').textContent = 'canvas server offline';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user