@vibevibes/mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,689 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>vibevibes</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ html, body { width: 100%; height: 100%; }
10
+ body { font-family: system-ui, -apple-system, sans-serif; background: #0a0a0a; color: #fff; }
11
+ #main-wrapper {
12
+ display: none;
13
+ width: 100%; height: 100%;
14
+ overflow: hidden;
15
+ }
16
+ #root { width: 100%; height: 100%; overflow: hidden; }
17
+ #loading {
18
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
19
+ height: 100vh; gap: 16px; color: #94a3b8;
20
+ }
21
+ #loading .spinner {
22
+ width: 32px; height: 32px; border: 2px solid #334155;
23
+ border-top-color: #6366f1; border-radius: 50%;
24
+ animation: spin 0.8s linear infinite;
25
+ }
26
+ @keyframes spin { to { transform: rotate(360deg); } }
27
+ #error-display {
28
+ display: none; padding: 32px; max-width: 600px; margin: 80px auto;
29
+ background: #1e1e2e; border: 1px solid #ef4444; border-radius: 12px;
30
+ }
31
+ #error-display h2 { color: #ef4444; margin-bottom: 12px; }
32
+ #error-display pre { font-size: 13px; color: #94a3b8; white-space: pre-wrap; word-break: break-word; }
33
+ #toast-container {
34
+ position: fixed; top: 16px; right: 16px; z-index: 9999;
35
+ display: flex; flex-direction: column; gap: 8px; max-width: 420px;
36
+ pointer-events: none;
37
+ }
38
+ .toast {
39
+ padding: 12px 16px; border-radius: 8px; font-size: 13px;
40
+ background: #1e1e2e; border: 1px solid #ef4444; color: #f87171;
41
+ white-space: pre-wrap; word-break: break-word;
42
+ animation: toastIn 0.2s ease-out;
43
+ cursor: pointer; pointer-events: auto;
44
+ }
45
+ .toast-build { border-color: #f59e0b; color: #fbbf24; font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 12px; max-height: 300px; overflow-y: auto; }
46
+ .toast-info { border-color: #6366f1; color: #818cf8; }
47
+ @keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <div id="loading">
52
+ <div class="spinner"></div>
53
+ <div>Loading experience...</div>
54
+ </div>
55
+ <div id="error-display">
56
+ <h2>Experience Error</h2>
57
+ <pre id="error-message"></pre>
58
+ </div>
59
+ <div id="toast-container"></div>
60
+ <div id="main-wrapper">
61
+ <div id="root"></div>
62
+ </div>
63
+
64
+ <!-- External deps from CDN -->
65
+ <script type="importmap">
66
+ {
67
+ "imports": {
68
+ "react": "https://esm.sh/react@18.2.0",
69
+ "react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
70
+ "react-dom": "https://esm.sh/react-dom@18.2.0",
71
+ "react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
72
+ "zod": "https://esm.sh/zod@3.22.4",
73
+ "yjs": "https://esm.sh/yjs@13.6.10",
74
+ "@vibevibes/sdk": "/sdk.js",
75
+ "@vibevibes/runtime": "/sdk.js"
76
+ }
77
+ }
78
+ </script>
79
+ <script type="module">
80
+ import React from "react";
81
+ import ReactDOM from "react-dom/client";
82
+ import { z } from "zod";
83
+ import * as Y from "yjs";
84
+
85
+ // ── Set up globalThis ────────────────────────────────────
86
+
87
+ // Wrap createElement to catch undefined component types
88
+ const _origCE = React.createElement;
89
+ React.createElement = function(type) {
90
+ if (type === undefined || type === null) {
91
+ console.error("[vibevibes] createElement called with undefined component. Props:", arguments[1]);
92
+ return _origCE("div", {
93
+ style: { padding: "8px", background: "#2d1b1b", border: "1px solid #ef4444",
94
+ borderRadius: "4px", color: "#f87171", fontSize: "12px" }
95
+ }, "[undefined component]");
96
+ }
97
+ return _origCE.apply(this, arguments);
98
+ };
99
+
100
+ globalThis.React = React;
101
+ globalThis.ReactDOM = ReactDOM;
102
+ globalThis.Y = Y;
103
+ globalThis.z = z;
104
+
105
+ // SDK define helpers (same contract as @vibevibes/sdk)
106
+ globalThis.defineExperience = (config) => config;
107
+ globalThis.defineTool = (config) => ({ risk: "low", capabilities_required: [], ...config });
108
+ globalThis.defineTest = (config) => config;
109
+ globalThis.defineStream = (config) => config;
110
+ globalThis.validateExperience = () => ({ valid: true, errors: [], warnings: [] });
111
+ globalThis.quickTool = (name, desc, schema, handler) =>
112
+ globalThis.defineTool({ name, description: desc, input_schema: schema, handler });
113
+ globalThis.phaseTool = (zod, validPhases) => ({
114
+ name: "_phase.set",
115
+ description: "Transition to a new phase" + (validPhases && validPhases.length > 0 ? ` (${validPhases.join(", ")})` : ""),
116
+ input_schema: validPhases && validPhases.length > 0 ? zod.object({ phase: zod.enum(validPhases) }) : zod.object({ phase: zod.string() }),
117
+ risk: "low", capabilities_required: ["state.write"],
118
+ handler: async (ctx, input) => { ctx.setState({ ...ctx.state, phase: input.phase }); return { phase: input.phase }; },
119
+ });
120
+
121
+ const { useState, useEffect, useCallback, useMemo, useRef, useContext, useReducer, createContext, forwardRef, memo } = React;
122
+ const ce = React.createElement;
123
+
124
+ // ── Toast notifications ─────────────────────────────────
125
+
126
+ function showToast(message, type = "error", durationMs = 6000) {
127
+ const container = document.getElementById("toast-container");
128
+ const toast = document.createElement("div");
129
+ const typeClass = type === "build" ? "toast-build" : type === "info" ? "toast-info" : "";
130
+ toast.className = "toast" + (typeClass ? " " + typeClass : "");
131
+ toast.textContent = message;
132
+ toast.onclick = () => toast.remove();
133
+ container.appendChild(toast);
134
+ setTimeout(() => { if (toast.parentNode) toast.remove(); }, durationMs);
135
+ }
136
+
137
+ // ── Connect to the experience ─────────────────────────────
138
+
139
+ const SERVER = location.origin;
140
+ const WS_PROTO = location.protocol === "https:" ? "wss:" : "ws:";
141
+ const WS_URL = `${WS_PROTO}//${location.host}`;
142
+ const ROOM_ID = "local";
143
+ let currentWs = null;
144
+
145
+ // ── Browser error reporting to agent ─────────────────────
146
+ function reportBrowserError(msg) {
147
+ try {
148
+ fetch("/browser-error", {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json" },
151
+ body: JSON.stringify({ message: String(msg), roomId: ROOM_ID }),
152
+ }).catch(() => {});
153
+ } catch {}
154
+ }
155
+
156
+ window.addEventListener("error", (e) => {
157
+ reportBrowserError(e.message || String(e));
158
+ });
159
+ window.addEventListener("unhandledrejection", (e) => {
160
+ reportBrowserError(e.reason?.message || String(e.reason));
161
+ });
162
+
163
+ function showError(msg) {
164
+ document.getElementById("loading").style.display = "none";
165
+ document.getElementById("main-wrapper").style.display = "none";
166
+ document.getElementById("error-display").style.display = "block";
167
+ document.getElementById("error-message").textContent = msg;
168
+ reportBrowserError(msg);
169
+ }
170
+
171
+ function escHtml(s) {
172
+ if (s == null || s === '') return '';
173
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
174
+ }
175
+
176
+ // Unique viewer session ID per tab
177
+ function getViewerSessionId() {
178
+ let id = sessionStorage.getItem("vibevibes_viewerSessionId");
179
+ if (!id) {
180
+ id = "viewer-" + Math.random().toString(36).slice(2, 10) + "-" + Date.now().toString(36);
181
+ sessionStorage.setItem("vibevibes_viewerSessionId", id);
182
+ }
183
+ return id;
184
+ }
185
+
186
+ // Participant details from server
187
+ let _participantDetails = [];
188
+ let _currentActorId = null;
189
+
190
+ async function start() {
191
+ const roomId = ROOM_ID;
192
+ const viewerOwner = getViewerSessionId();
193
+ try {
194
+ const ws = new WebSocket(WS_URL);
195
+ currentWs = ws;
196
+
197
+ let actorId = "viewer";
198
+ let sharedState = {};
199
+ let _stateVersion = 0;
200
+ let participants = [];
201
+ let roomConfig = {};
202
+ let Canvas = null;
203
+ let EntryRitual = null;
204
+ let ritualComplete = false;
205
+ let hotReloadKey = 0;
206
+ let optimisticOverlays = new Map();
207
+ let optimisticSeq = 0;
208
+
209
+ function getDisplayState() {
210
+ if (optimisticOverlays.size === 0) return sharedState;
211
+ let merged = { ...sharedState };
212
+ for (const overlay of optimisticOverlays.values()) {
213
+ Object.assign(merged, overlay.state);
214
+ }
215
+ return merged;
216
+ }
217
+
218
+ // Ephemeral state (high-frequency, no tool gate)
219
+ let ephemeralState = {};
220
+
221
+ function setEphemeral(data) {
222
+ ephemeralState = {
223
+ ...ephemeralState,
224
+ [actorId]: { ...(ephemeralState[actorId] || {}), ...data },
225
+ };
226
+ if (ws.readyState === WebSocket.OPEN) {
227
+ ws.send(JSON.stringify({ type: "ephemeral", actorId, data }));
228
+ }
229
+ render();
230
+ }
231
+
232
+ function streamFn(name, input) {
233
+ if (ws.readyState === WebSocket.OPEN) {
234
+ ws.send(JSON.stringify({ type: "stream", name, input, actorId }));
235
+ }
236
+ }
237
+
238
+ function render() {
239
+ if (!Canvas || !reactRoot) return;
240
+
241
+ const displayState = getDisplayState();
242
+
243
+ globalThis.__vibevibes_ctx__ = {
244
+ sharedState: displayState,
245
+ callTool,
246
+ participants,
247
+ actorId,
248
+ ephemeralState,
249
+ setEphemeral,
250
+ };
251
+
252
+ if (EntryRitual && !ritualComplete) {
253
+ reactRoot.render(
254
+ ce(CanvasErrorBoundary, { key: hotReloadKey },
255
+ ce(EntryRitual, {
256
+ actorId, participants, participantDetails: _participantDetails,
257
+ callTool,
258
+ proceed: function() { ritualComplete = true; render(); },
259
+ roomConfig,
260
+ })
261
+ )
262
+ );
263
+ return;
264
+ }
265
+
266
+ reactRoot.render(
267
+ ce(CanvasErrorBoundary, { key: hotReloadKey },
268
+ ce(Canvas, {
269
+ roomId,
270
+ actorId,
271
+ sharedState: displayState,
272
+ callTool,
273
+ participants,
274
+ participantDetails: _participantDetails,
275
+ ephemeralState,
276
+ setEphemeral,
277
+ roomConfig,
278
+ stream: streamFn,
279
+ role: "player",
280
+ })
281
+ )
282
+ );
283
+ }
284
+
285
+ async function callTool(name, input, _optimisticState) {
286
+ if (!/^[a-zA-Z0-9_.\-:]+$/.test(name)) { throw new Error("Invalid tool name"); }
287
+ let overlayId = null;
288
+ if (_optimisticState) {
289
+ overlayId = ++optimisticSeq;
290
+ optimisticOverlays.set(overlayId, { state: _optimisticState, ts: Date.now() });
291
+ render();
292
+ }
293
+
294
+ try {
295
+ const res = await fetch(`${SERVER}/tools/${name}`, {
296
+ method: "POST",
297
+ headers: { "Content-Type": "application/json" },
298
+ body: JSON.stringify({ actorId, input: input || {} }),
299
+ });
300
+ const data = await res.json();
301
+ if (data.error) {
302
+ showToast(data.error);
303
+ throw new Error(data.error);
304
+ }
305
+ return data.output;
306
+ } catch (err) {
307
+ if (!document.querySelector(".toast")) {
308
+ showToast(`Tool '${name}' failed: ${err.message || "unknown error"}`);
309
+ }
310
+ throw err;
311
+ } finally {
312
+ if (overlayId !== null) {
313
+ optimisticOverlays.delete(overlayId);
314
+ render();
315
+ }
316
+ }
317
+ }
318
+
319
+ // Error boundary component
320
+ function getErrorHint(message) {
321
+ if (!message) return null;
322
+ if (message.includes("Cannot read properties of undefined") || message.includes("Cannot read property"))
323
+ return "A component accessed state that hasn't been initialized. Check your initialState and ensure all keys exist before reading them.";
324
+ if (message.includes("is not a function"))
325
+ return "Something expected to be a function is not. Check for missing imports or incorrect prop types.";
326
+ if (message.includes("Maximum update depth"))
327
+ return "Infinite re-render loop detected. Check useEffect dependencies — avoid setting state unconditionally inside useEffect.";
328
+ if (message.includes("Invalid hook call"))
329
+ return "Hooks can only be called inside React function components. Check that you're not calling useState/useEffect outside a component.";
330
+ if (message.includes("is not defined"))
331
+ return "A variable or import is missing. Check that all imports from @vibevibes/sdk or other modules are correct.";
332
+ return null;
333
+ }
334
+
335
+ class CanvasErrorBoundary extends React.Component {
336
+ constructor(props) {
337
+ super(props);
338
+ this.state = { error: null, componentStack: null };
339
+ }
340
+ static getDerivedStateFromError(error) { return { error }; }
341
+ componentDidCatch(error, info) {
342
+ console.error("[viewer] Experience crashed:", error, info?.componentStack);
343
+ if (info?.componentStack) this.setState({ componentStack: info.componentStack });
344
+ reportBrowserError(`[Canvas crash] ${error.message || error}`);
345
+ }
346
+ render() {
347
+ if (this.state.error) {
348
+ const msg = this.state.error.message || String(this.state.error);
349
+ const hint = getErrorHint(msg);
350
+ const stack = this.state.componentStack;
351
+ const fullError = msg + (stack ? "\n\nComponent stack:" + stack : "");
352
+
353
+ return ce("div", {
354
+ style: { padding: "32px", textAlign: "center", color: "#ef4444", maxWidth: "640px", margin: "0 auto" },
355
+ },
356
+ ce("h2", null, "Experience Crashed"),
357
+ ce("pre", { style: { fontSize: "13px", color: "#94a3b8", marginTop: "12px", whiteSpace: "pre-wrap", textAlign: "left" } }, msg),
358
+ hint ? ce("div", {
359
+ style: { marginTop: "12px", padding: "12px 16px", background: "#1e293b", borderRadius: "8px", border: "1px solid #334155", textAlign: "left" },
360
+ },
361
+ ce("strong", { style: { color: "#fbbf24", fontSize: "13px" } }, "Hint: "),
362
+ ce("span", { style: { color: "#cbd5e1", fontSize: "13px" } }, hint),
363
+ ) : null,
364
+ stack ? ce("details", { style: { marginTop: "12px", textAlign: "left" } },
365
+ ce("summary", { style: { color: "#64748b", cursor: "pointer", fontSize: "12px" } }, "Component stack"),
366
+ ce("pre", { style: { fontSize: "11px", color: "#64748b", marginTop: "4px", whiteSpace: "pre-wrap" } }, stack),
367
+ ) : null,
368
+ ce("div", { style: { marginTop: "16px", display: "flex", gap: "8px", justifyContent: "center" } },
369
+ ce("button", {
370
+ onClick: () => this.setState({ error: null, componentStack: null }),
371
+ style: { padding: "8px 16px", background: "#6366f1", color: "#fff", border: "none", borderRadius: "6px", cursor: "pointer" },
372
+ }, "Try Again"),
373
+ ce("button", {
374
+ onClick: () => { navigator.clipboard.writeText(fullError); showToast("Error copied to clipboard"); },
375
+ style: { padding: "8px 16px", background: "#334155", color: "#cbd5e1", border: "none", borderRadius: "6px", cursor: "pointer" },
376
+ }, "Copy Error"),
377
+ ),
378
+ );
379
+ }
380
+ return this.props.children;
381
+ }
382
+ }
383
+
384
+ ws.onopen = () => {
385
+ reconnectAttempts = 0;
386
+ const savedActorId = sessionStorage.getItem("vibevibes_actorId");
387
+ ws.send(JSON.stringify({ type: "join", username: "viewer", roomId, actorId: savedActorId, owner: viewerOwner }));
388
+ };
389
+
390
+ ws.onmessage = async (event) => {
391
+ if (currentWs !== ws) return;
392
+ let msg;
393
+ try { msg = JSON.parse(event.data); } catch { return; }
394
+
395
+ if (msg.type === "error") {
396
+ console.warn("[viewer] Server error:", msg.error);
397
+ const savedId = sessionStorage.getItem("vibevibes_actorId");
398
+ if (savedId) {
399
+ sessionStorage.removeItem("vibevibes_actorId");
400
+ ws.send(JSON.stringify({ type: "join", username: "viewer", roomId, owner: viewerOwner }));
401
+ } else {
402
+ showError(`Server error: ${msg.error}`);
403
+ }
404
+ }
405
+
406
+ if (msg.type === "joined") {
407
+ actorId = msg.actorId;
408
+ _currentActorId = actorId;
409
+ sessionStorage.setItem("vibevibes_actorId", actorId);
410
+ sharedState = msg.sharedState || {};
411
+ _stateVersion = msg.stateVersion ?? 0;
412
+ participants = msg.participants || [];
413
+ if (msg.participantDetails) _participantDetails = msg.participantDetails;
414
+ roomConfig = msg.config || {};
415
+
416
+ // Load client bundle
417
+ const bundleUrl = `${SERVER}/bundle?_t=${Date.now()}`;
418
+ let bundleLoaded = false;
419
+ for (let attempt = 0; attempt < 3 && !bundleLoaded; attempt++) {
420
+ try {
421
+ if (attempt > 0) await new Promise(r => setTimeout(r, 500 * attempt));
422
+ const bundleRes = await fetch(bundleUrl + "&r=" + attempt);
423
+ if (!bundleRes.ok) throw new Error(`Bundle fetch failed: ${bundleRes.status} ${bundleRes.statusText}`);
424
+ const bundleCode = await bundleRes.text();
425
+
426
+ // Protocol experience: empty bundle -> load HTML canvas in iframe
427
+ if (!bundleCode.trim()) {
428
+ const canvasUrl = `${SERVER}/rooms/${roomId}/canvas?actorId=${encodeURIComponent(actorId)}&roomId=${encodeURIComponent(roomId)}`;
429
+ const root = document.getElementById("root");
430
+ root.innerHTML = "";
431
+ const iframe = document.createElement("iframe");
432
+ iframe.src = canvasUrl;
433
+ iframe.style.cssText = "width:100%;height:100%;border:none;";
434
+ root.appendChild(iframe);
435
+ Canvas = null;
436
+ document.getElementById("loading").style.display = "none";
437
+ document.getElementById("main-wrapper").style.display = "block";
438
+ bundleLoaded = true;
439
+ break;
440
+ }
441
+
442
+ const mod = await import(bundleUrl + "&r=" + attempt);
443
+ const experience = mod.default ?? mod;
444
+ if (!experience?.Canvas) throw new Error("Experience has no Canvas component");
445
+
446
+ Canvas = experience.Canvas;
447
+ EntryRitual = experience.entryRitual || null;
448
+ ritualComplete = false;
449
+ if (!reactRoot) {
450
+ reactRoot = ReactDOM.createRoot(document.getElementById("root"));
451
+ }
452
+
453
+ document.getElementById("loading").style.display = "none";
454
+ document.getElementById("main-wrapper").style.display = "block";
455
+ // Set page title from experience manifest
456
+ const expTitle = experience.manifest?.title || experience.name;
457
+ if (expTitle) document.title = expTitle;
458
+ render();
459
+ bundleLoaded = true;
460
+ } catch (err) {
461
+ console.warn(`[viewer] Bundle load attempt ${attempt + 1} failed:`, err.message);
462
+ if (attempt >= 2) showError(`Failed to load experience: ${err.message}`);
463
+ }
464
+ }
465
+ }
466
+
467
+ if (msg.type === "shared_state_update") {
468
+ const serverVersion = msg.stateVersion;
469
+
470
+ if (msg.state) {
471
+ sharedState = msg.state;
472
+ } else if (msg.delta) {
473
+ if (serverVersion && _stateVersion > 0 && serverVersion > _stateVersion + 1) {
474
+ console.warn(`[viewer] State version gap: ${_stateVersion} → ${serverVersion}, recovering...`);
475
+ fetch(`${SERVER}/state`)
476
+ .then(r => r.ok ? r.json() : null)
477
+ .then(data => {
478
+ if (data?.sharedState) {
479
+ sharedState = data.sharedState;
480
+ optimisticOverlays.clear();
481
+ render();
482
+ }
483
+ })
484
+ .catch(() => {});
485
+ }
486
+ sharedState = { ...sharedState, ...msg.delta };
487
+ if (msg.deletedKeys) {
488
+ for (const key of msg.deletedKeys) {
489
+ delete sharedState[key];
490
+ }
491
+ }
492
+ }
493
+
494
+ if (serverVersion) _stateVersion = serverVersion;
495
+ participants = msg.participants || participants;
496
+ optimisticOverlays.clear();
497
+ render();
498
+ if (msg.event) {
499
+ const ev = msg.event;
500
+ if (ev.tool && ev.actorId && !ev.actorId.startsWith("_tick")) {
501
+ const isAI = _participantDetails.find(d => d.actorId === ev.actorId)?.type === "ai";
502
+ const tag = isAI ? "\u{1F916}" : "\u{1F464}";
503
+ const inputStr = ev.input ? JSON.stringify(ev.input) : "{}";
504
+ const outputStr = ev.error ? `\u274C ${ev.error}` : (ev.output ? JSON.stringify(ev.output) : "ok");
505
+ console.log(`%c[ACTION] ${tag} ${ev.actorId} \u2192 ${ev.tool}(${inputStr}) \u2192 ${outputStr}`, "color: #60a5fa; font-size: 12px");
506
+ }
507
+ }
508
+ }
509
+
510
+ if (msg.type === "ephemeral") {
511
+ const senderId = msg.actorId;
512
+ if (senderId && senderId !== actorId) {
513
+ ephemeralState = {
514
+ ...ephemeralState,
515
+ [senderId]: { ...(ephemeralState[senderId] || {}), ...msg.data },
516
+ };
517
+ render();
518
+ }
519
+ }
520
+
521
+ if (msg.type === "presence_update") {
522
+ const prevSet = new Set(participants);
523
+ participants = msg.participants || participants;
524
+ if (msg.participantDetails) _participantDetails = msg.participantDetails;
525
+ const newSet = new Set(participants);
526
+ for (const pid of newSet) {
527
+ if (!prevSet.has(pid)) {
528
+ const detail = _participantDetails.find(d => d.actorId === pid);
529
+ const typeTag = detail?.type === "ai" ? "\u{1F916}" : detail?.type === "human" ? "\u{1F464}" : "\u2753";
530
+ const roleTag = detail?.role ? ` [${detail.role}]` : "";
531
+ const ownerTag = detail?.owner ? ` (owner: ${detail.owner})` : "";
532
+ console.log(`%c[AGENT JOIN] ${typeTag} ${pid}${roleTag}${ownerTag}`, "color: #4ade80; font-weight: bold; font-size: 13px");
533
+ }
534
+ }
535
+ for (const pid of prevSet) {
536
+ if (!newSet.has(pid)) {
537
+ const detail = _participantDetails.find(d => d.actorId === pid);
538
+ const typeTag = detail?.type === "ai" ? "\u{1F916}" : detail?.type === "human" ? "\u{1F464}" : "\u2753";
539
+ const roleTag = detail?.role ? ` [${detail.role}]` : "";
540
+ console.log(`%c[AGENT LEAVE] ${typeTag} ${pid}${roleTag}`, "color: #f87171; font-weight: bold; font-size: 13px");
541
+ }
542
+ }
543
+ // Prune ephemeral state for departed participants
544
+ const activeSet = new Set(participants);
545
+ for (const key of Object.keys(ephemeralState)) {
546
+ if (!activeSet.has(key)) delete ephemeralState[key];
547
+ }
548
+ render();
549
+ }
550
+
551
+ if (msg.type === "experience_updated") {
552
+ try {
553
+ const bundleUrl = `${SERVER}/bundle?_t=${Date.now()}`;
554
+ const bundleRes = await fetch(bundleUrl);
555
+ if (!bundleRes.ok) throw new Error(`Bundle fetch failed: ${bundleRes.status} ${bundleRes.statusText}`);
556
+ const bundleCode = await bundleRes.text();
557
+
558
+ const blob = new Blob([bundleCode], { type: "text/javascript" });
559
+ const url = URL.createObjectURL(blob);
560
+ const mod = await import(url);
561
+ URL.revokeObjectURL(url);
562
+
563
+ const experience = mod.default ?? mod;
564
+ if (experience?.Canvas) {
565
+ Canvas = experience.Canvas;
566
+ EntryRitual = experience.entryRitual || null;
567
+ ritualComplete = false;
568
+ hotReloadKey++;
569
+ const expTitle = experience.manifest?.title || experience.name;
570
+ if (expTitle) document.title = expTitle;
571
+ render();
572
+ console.log("[viewer] Hot reloaded");
573
+ }
574
+ } catch (err) {
575
+ console.error("[viewer] Hot reload failed:", err);
576
+ showToast(`Hot reload failed: ${err.message}`, "build", 10000);
577
+ }
578
+ }
579
+
580
+ if (msg.type === "build_error") {
581
+ showToast(`Build error:\n${msg.error}`, "build", 15000);
582
+ }
583
+
584
+ if (msg.type === "screenshot_request") {
585
+ const requestId = msg.id;
586
+ setTimeout(async () => {
587
+ try {
588
+ const root = document.getElementById("root");
589
+ if (!root) throw new Error("No #root element");
590
+
591
+ let dataUrl;
592
+
593
+ // Fast path: single <canvas> filling #root -> native toDataURL
594
+ const canvases = root.querySelectorAll("canvas");
595
+ if (canvases.length === 1) {
596
+ const cvs = canvases[0];
597
+ const rootRect = root.getBoundingClientRect();
598
+ const cvsRect = cvs.getBoundingClientRect();
599
+ const fillsRoot =
600
+ Math.abs(cvsRect.width - rootRect.width) < 20 &&
601
+ Math.abs(cvsRect.height - rootRect.height) < 20;
602
+ if (fillsRoot) {
603
+ dataUrl = cvs.toDataURL("image/png");
604
+ }
605
+ }
606
+
607
+ // Snapshot all canvases before html2canvas
608
+ const canvasSnapshots = new Map();
609
+ if (!dataUrl) {
610
+ canvases.forEach((cvs) => {
611
+ try {
612
+ const snap = cvs.toDataURL("image/png");
613
+ if (snap && snap.length > 100) {
614
+ canvasSnapshots.set(cvs, snap);
615
+ }
616
+ } catch {}
617
+ });
618
+ }
619
+
620
+ // Fallback: html2canvas for DOM/SVG/mixed content
621
+ if (!dataUrl && typeof html2canvas === "function") {
622
+ const captured = await html2canvas(root, {
623
+ backgroundColor: "#0a0a0a",
624
+ useCORS: true,
625
+ logging: false,
626
+ scale: 1,
627
+ onclone: (doc, clonedRoot) => {
628
+ const clonedCanvases = clonedRoot.querySelectorAll("canvas");
629
+ clonedCanvases.forEach((clonedCvs, idx) => {
630
+ const originalCvs = canvases[idx];
631
+ const snapshot = canvasSnapshots.get(originalCvs);
632
+ if (snapshot) {
633
+ const img = doc.createElement("img");
634
+ img.src = snapshot;
635
+ img.style.width = clonedCvs.style.width || (clonedCvs.width + "px");
636
+ img.style.height = clonedCvs.style.height || (clonedCvs.height + "px");
637
+ img.style.display = clonedCvs.style.display;
638
+ clonedCvs.parentNode?.replaceChild(img, clonedCvs);
639
+ }
640
+ });
641
+ },
642
+ });
643
+ dataUrl = captured.toDataURL("image/png");
644
+ }
645
+
646
+ if (!dataUrl) throw new Error("No capture method available");
647
+
648
+ try { ws.send(JSON.stringify({
649
+ type: "screenshot_response",
650
+ id: requestId,
651
+ dataUrl,
652
+ })); } catch (_) {}
653
+ } catch (err) {
654
+ try { ws.send(JSON.stringify({
655
+ type: "screenshot_response",
656
+ id: requestId,
657
+ error: err.message,
658
+ })); } catch (_) {}
659
+ }
660
+ }, 100);
661
+ }
662
+ };
663
+
664
+ ws.onerror = (err) => {
665
+ console.error("[viewer] WebSocket error:", err);
666
+ };
667
+
668
+ ws.onclose = () => {
669
+ if (currentWs !== ws) return;
670
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 15000);
671
+ reconnectAttempts++;
672
+ console.log(`[viewer] WebSocket closed, reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`);
673
+ showToast(`Disconnected. Reconnecting in ${Math.round(delay/1000)}s...`, "info", delay + 1000);
674
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; if (currentWs === ws) start(); }, delay);
675
+ };
676
+
677
+ } catch (err) {
678
+ showError(err.message);
679
+ }
680
+ }
681
+
682
+ let reconnectAttempts = 0;
683
+ let reconnectTimer = null;
684
+ let reactRoot = null;
685
+
686
+ start();
687
+ </script>
688
+ </body>
689
+ </html>