@vibevibes/runtime 0.2.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.
- package/dist/bundler.d.ts +36 -0
- package/dist/bundler.d.ts.map +1 -0
- package/dist/bundler.js +365 -0
- package/dist/bundler.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +92 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +241 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +3339 -0
- package/dist/server.js.map +1 -0
- package/dist/tick-engine.d.ts +82 -0
- package/dist/tick-engine.d.ts.map +1 -0
- package/dist/tick-engine.js +153 -0
- package/dist/tick-engine.js.map +1 -0
- package/dist/viewer/index.html +1388 -0
- package/package.json +53 -0
- package/src/bundler.ts +429 -0
- package/src/index.ts +26 -0
- package/src/protocol.ts +324 -0
- package/src/server.ts +3738 -0
- package/src/tick-engine.ts +216 -0
- package/src/viewer/index.html +1388 -0
|
@@ -0,0 +1,1388 @@
|
|
|
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>vibe-vibe</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 layout ───────────────────────────────────────── */
|
|
12
|
+
#main-wrapper {
|
|
13
|
+
display: none;
|
|
14
|
+
height: calc(100% - 40px);
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
}
|
|
17
|
+
#root { flex: 1; min-width: 0; height: 100%; overflow: hidden; transition: all 0.2s ease; }
|
|
18
|
+
/* ── Right sidebar ─────────────────────────────────────── */
|
|
19
|
+
#right-sidebar {
|
|
20
|
+
width: 0; height: 100%; overflow: hidden;
|
|
21
|
+
background: #111113; border-left: 1px solid #1e1e24;
|
|
22
|
+
transition: width 0.2s ease;
|
|
23
|
+
display: flex; flex-direction: column; flex-shrink: 0;
|
|
24
|
+
}
|
|
25
|
+
#right-sidebar.open { width: 320px; }
|
|
26
|
+
#sidebar-tabs {
|
|
27
|
+
display: flex; border-bottom: 1px solid #1e1e24; flex-shrink: 0;
|
|
28
|
+
}
|
|
29
|
+
#sidebar-tabs button {
|
|
30
|
+
flex: 1; padding: 10px; background: none; border: none;
|
|
31
|
+
color: #6b6b80; font-size: 12px; font-weight: 600; cursor: pointer;
|
|
32
|
+
text-transform: uppercase; letter-spacing: 0.05em;
|
|
33
|
+
border-bottom: 2px solid transparent; transition: color 0.15s;
|
|
34
|
+
}
|
|
35
|
+
#sidebar-tabs button.active { color: #6366f1; border-bottom-color: #6366f1; }
|
|
36
|
+
#sidebar-tabs button:hover:not(.active) { color: #94a3b8; }
|
|
37
|
+
#sidebar-chat, #sidebar-activity {
|
|
38
|
+
flex: 1; overflow: hidden; display: none; flex-direction: column;
|
|
39
|
+
}
|
|
40
|
+
#sidebar-chat.active, #sidebar-activity.active { display: flex; }
|
|
41
|
+
/* ── Topbar ──────────────────────────────────────────── */
|
|
42
|
+
#topbar {
|
|
43
|
+
height: 40px; background: #111113; border-bottom: 1px solid #1e1e24;
|
|
44
|
+
display: flex; align-items: center; padding: 0 12px; gap: 8px;
|
|
45
|
+
font-size: 13px; z-index: 10000; position: relative;
|
|
46
|
+
}
|
|
47
|
+
.topbar-title { color: #e2e2e8; font-weight: 600; font-size: 13px; margin-left: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
48
|
+
.topbar-participants { display: flex; gap: 4px; align-items: center; margin-right: 4px; }
|
|
49
|
+
.topbar-participant {
|
|
50
|
+
padding: 2px 8px; border-radius: 9999px; font-size: 10px; font-weight: 500;
|
|
51
|
+
white-space: nowrap; cursor: pointer; position: relative;
|
|
52
|
+
transition: filter 0.15s;
|
|
53
|
+
}
|
|
54
|
+
.topbar-participant:hover { filter: brightness(1.3); }
|
|
55
|
+
.topbar-participant.human { background: #1e293b; color: #60a5fa; }
|
|
56
|
+
.topbar-participant.ai { background: #1a1625; color: #a78bfa; }
|
|
57
|
+
.topbar-participant.unknown { background: #1e1e24; color: #6b6b80; }
|
|
58
|
+
.participant-dropdown {
|
|
59
|
+
position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
|
|
60
|
+
margin-top: 6px; background: #1e1e2e; border: 1px solid #2e2e3e;
|
|
61
|
+
border-radius: 8px; padding: 4px; min-width: 140px;
|
|
62
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10001;
|
|
63
|
+
animation: dropdownIn 0.12s ease-out;
|
|
64
|
+
}
|
|
65
|
+
@keyframes dropdownIn { from { opacity: 0; transform: translateX(-50%) translateY(-4px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
|
|
66
|
+
.participant-dropdown .dd-header {
|
|
67
|
+
padding: 6px 10px; font-size: 11px; color: #6b6b80;
|
|
68
|
+
border-bottom: 1px solid #2e2e3e; margin-bottom: 2px;
|
|
69
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
70
|
+
}
|
|
71
|
+
.participant-dropdown button {
|
|
72
|
+
display: flex; align-items: center; gap: 6px; width: 100%;
|
|
73
|
+
padding: 7px 10px; background: none; border: none;
|
|
74
|
+
color: #e2e2e8; font-size: 12px; cursor: pointer;
|
|
75
|
+
border-radius: 4px; text-align: left;
|
|
76
|
+
}
|
|
77
|
+
.participant-dropdown button:hover { background: #2e2e3e; }
|
|
78
|
+
.participant-dropdown button.kick-action { color: #f87171; }
|
|
79
|
+
.participant-dropdown button.kick-action:hover { background: #3b1c1c; }
|
|
80
|
+
.topbar-btn {
|
|
81
|
+
background: none; border: none; color: #94a3b8; cursor: pointer;
|
|
82
|
+
font-size: 16px; padding: 4px 8px; border-radius: 4px;
|
|
83
|
+
display: flex; align-items: center; justify-content: center;
|
|
84
|
+
transition: background 0.15s, color 0.15s; flex-shrink: 0;
|
|
85
|
+
}
|
|
86
|
+
.topbar-btn:hover { background: #1e1e2e; color: #e2e2e8; }
|
|
87
|
+
/* ── Library ─────────────────────────────────────────── */
|
|
88
|
+
#library {
|
|
89
|
+
display: none; width: 100%; height: calc(100% - 40px);
|
|
90
|
+
overflow-y: auto; padding: 32px; max-width: 720px; margin: 0 auto;
|
|
91
|
+
}
|
|
92
|
+
#library.active { display: block; }
|
|
93
|
+
#library h2 { font-size: 20px; font-weight: 700; margin-bottom: 20px; color: #e2e2e8; }
|
|
94
|
+
#library h3 { font-size: 14px; font-weight: 600; color: #6b6b80; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px; margin-top: 24px; }
|
|
95
|
+
.lib-card {
|
|
96
|
+
background: #111113; border: 1px solid #1e1e24; border-radius: 8px;
|
|
97
|
+
padding: 14px 16px; margin-bottom: 8px; cursor: pointer;
|
|
98
|
+
transition: border-color 0.15s, background 0.15s;
|
|
99
|
+
}
|
|
100
|
+
.lib-card:hover { border-color: #6366f1; background: #16161a; }
|
|
101
|
+
.lib-card .title { font-size: 14px; font-weight: 600; color: #e2e2e8; }
|
|
102
|
+
.lib-card .desc { font-size: 12px; color: #6b6b80; margin-top: 4px; }
|
|
103
|
+
.lib-card .meta { font-size: 11px; color: #4a4a5a; margin-top: 6px; display: flex; gap: 12px; }
|
|
104
|
+
.lib-empty { color: #4a4a5a; font-size: 13px; padding: 16px 0; }
|
|
105
|
+
#loading {
|
|
106
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
107
|
+
height: calc(100vh - 40px); gap: 16px; color: #94a3b8;
|
|
108
|
+
}
|
|
109
|
+
#loading .spinner {
|
|
110
|
+
width: 32px; height: 32px; border: 2px solid #334155;
|
|
111
|
+
border-top-color: #6366f1; border-radius: 50%;
|
|
112
|
+
animation: spin 0.8s linear infinite;
|
|
113
|
+
}
|
|
114
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
115
|
+
#error-display {
|
|
116
|
+
display: none; padding: 32px; max-width: 600px; margin: 80px auto;
|
|
117
|
+
background: #1e1e2e; border: 1px solid #ef4444; border-radius: 12px;
|
|
118
|
+
}
|
|
119
|
+
#error-display h2 { color: #ef4444; margin-bottom: 12px; }
|
|
120
|
+
#error-display pre { font-size: 13px; color: #94a3b8; white-space: pre-wrap; word-break: break-word; }
|
|
121
|
+
#toast-container {
|
|
122
|
+
position: fixed; top: 16px; right: 16px; z-index: 9999;
|
|
123
|
+
display: flex; flex-direction: column; gap: 8px; max-width: 420px;
|
|
124
|
+
pointer-events: none;
|
|
125
|
+
}
|
|
126
|
+
.toast {
|
|
127
|
+
padding: 12px 16px; border-radius: 8px; font-size: 13px;
|
|
128
|
+
background: #1e1e2e; border: 1px solid #ef4444; color: #f87171;
|
|
129
|
+
white-space: pre-wrap; word-break: break-word;
|
|
130
|
+
animation: toastIn 0.2s ease-out;
|
|
131
|
+
cursor: pointer; pointer-events: auto;
|
|
132
|
+
}
|
|
133
|
+
.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; }
|
|
134
|
+
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
|
135
|
+
/* ── Activity log (inside sidebar) ─────────────────── */
|
|
136
|
+
.sidebar-panel-header {
|
|
137
|
+
padding: 12px 16px; border-bottom: 1px solid #1e1e24;
|
|
138
|
+
font-size: 12px; font-weight: 700; color: #6b6b80;
|
|
139
|
+
text-transform: uppercase; letter-spacing: 0.06em;
|
|
140
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
141
|
+
flex-shrink: 0;
|
|
142
|
+
}
|
|
143
|
+
#participant-list {
|
|
144
|
+
padding: 8px 16px; border-bottom: 1px solid #1e1e24;
|
|
145
|
+
display: flex; flex-wrap: wrap; gap: 6px; flex-shrink: 0;
|
|
146
|
+
}
|
|
147
|
+
.participant-chip {
|
|
148
|
+
padding: 3px 10px; border-radius: 9999px; font-size: 11px; font-weight: 500;
|
|
149
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
150
|
+
}
|
|
151
|
+
.participant-chip .kick-btn {
|
|
152
|
+
background: none; border: none; color: #4a4a5a; cursor: pointer;
|
|
153
|
+
font-size: 12px; padding: 0; line-height: 1;
|
|
154
|
+
opacity: 0; transition: opacity 0.15s;
|
|
155
|
+
}
|
|
156
|
+
.participant-chip:hover .kick-btn { opacity: 1; }
|
|
157
|
+
.participant-chip .kick-btn:hover { color: #ef4444; }
|
|
158
|
+
.participant-chip.human { background: #1e293b; color: #60a5fa; }
|
|
159
|
+
.participant-chip.ai { background: #1a1625; color: #a78bfa; }
|
|
160
|
+
.participant-chip.unknown { background: #1e1e24; color: #6b6b80; }
|
|
161
|
+
#event-log {
|
|
162
|
+
flex: 1; overflow-y: auto; padding: 8px 0;
|
|
163
|
+
}
|
|
164
|
+
.event-entry {
|
|
165
|
+
padding: 6px 16px; font-size: 12px; line-height: 1.5;
|
|
166
|
+
border-bottom: 1px solid #0a0a0a;
|
|
167
|
+
}
|
|
168
|
+
.event-entry:hover { background: #1e1e24; }
|
|
169
|
+
.event-actor { font-weight: 600; }
|
|
170
|
+
.event-actor.human { color: #60a5fa; }
|
|
171
|
+
.event-actor.ai { color: #a78bfa; }
|
|
172
|
+
.event-tool { color: #6366f1; font-weight: 500; }
|
|
173
|
+
.event-time { color: #4a4a5a; font-size: 10px; float: right; }
|
|
174
|
+
.event-error { color: #f87171; }
|
|
175
|
+
/* ── Toast for share link ──────────────────────────── */
|
|
176
|
+
.toast-info { border-color: #6366f1; color: #818cf8; }
|
|
177
|
+
</style>
|
|
178
|
+
</head>
|
|
179
|
+
<body>
|
|
180
|
+
<div id="topbar">
|
|
181
|
+
<button class="topbar-btn" id="btn-back" onclick="navigateToLibrary()" title="Back to library">←</button>
|
|
182
|
+
<span class="topbar-title" id="topbar-title"></span>
|
|
183
|
+
<div style="flex:1"></div>
|
|
184
|
+
<div class="topbar-participants" id="topbar-participants"></div>
|
|
185
|
+
<button class="topbar-btn" id="btn-reset" onclick="resetRoom()" title="Reset experience">↺</button>
|
|
186
|
+
<button class="topbar-btn" id="btn-share" onclick="copyShareLink()" title="Copy link">🔗</button>
|
|
187
|
+
<button class="topbar-btn" id="btn-sidebar" onclick="toggleSidebar()" title="Toggle sidebar">☰</button>
|
|
188
|
+
</div>
|
|
189
|
+
<div id="library"></div>
|
|
190
|
+
<div id="loading">
|
|
191
|
+
<div class="spinner"></div>
|
|
192
|
+
<div>Loading experience...</div>
|
|
193
|
+
</div>
|
|
194
|
+
<div id="error-display">
|
|
195
|
+
<h2>Experience Error</h2>
|
|
196
|
+
<pre id="error-message"></pre>
|
|
197
|
+
</div>
|
|
198
|
+
<div id="toast-container"></div>
|
|
199
|
+
<div id="main-wrapper">
|
|
200
|
+
<div id="root"></div>
|
|
201
|
+
<div id="right-sidebar">
|
|
202
|
+
<div id="sidebar-tabs">
|
|
203
|
+
<button class="active" onclick="switchSidebarTab('chat')">Chat</button>
|
|
204
|
+
<button onclick="switchSidebarTab('activity')">Activity</button>
|
|
205
|
+
</div>
|
|
206
|
+
<div id="sidebar-chat" class="active"></div>
|
|
207
|
+
<div id="sidebar-activity">
|
|
208
|
+
<div class="sidebar-panel-header">
|
|
209
|
+
<span>Participants</span>
|
|
210
|
+
<span id="participant-count">0</span>
|
|
211
|
+
</div>
|
|
212
|
+
<div id="participant-list"></div>
|
|
213
|
+
<div class="sidebar-panel-header">
|
|
214
|
+
<span>Activity</span>
|
|
215
|
+
<span id="event-count">0</span>
|
|
216
|
+
</div>
|
|
217
|
+
<div id="event-log"></div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<!-- External deps from CDN -->
|
|
223
|
+
<script type="importmap">
|
|
224
|
+
{
|
|
225
|
+
"imports": {
|
|
226
|
+
"react": "https://esm.sh/react@18.2.0",
|
|
227
|
+
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
|
|
228
|
+
"react-dom": "https://esm.sh/react-dom@18.2.0",
|
|
229
|
+
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
|
|
230
|
+
"zod": "https://esm.sh/zod@3.22.4",
|
|
231
|
+
"yjs": "https://esm.sh/yjs@13.6.10",
|
|
232
|
+
"@vibevibes/sdk": "/sdk.js"
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
</script>
|
|
236
|
+
<!-- Screenshot capture (html2canvas for DOM/SVG, canvas.toDataURL for <canvas>) -->
|
|
237
|
+
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
|
238
|
+
<script type="module">
|
|
239
|
+
import React from "react";
|
|
240
|
+
import ReactDOM from "react-dom/client";
|
|
241
|
+
import { z } from "zod";
|
|
242
|
+
import * as Y from "yjs";
|
|
243
|
+
|
|
244
|
+
// ── Set up globalThis ────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
// Wrap createElement to catch undefined component types — the O(1) safety net.
|
|
247
|
+
// When a bundle variable resolves to undefined (aliasing issues, missing globals,
|
|
248
|
+
// browser cache), this catches it at render time and shows a useful error instead
|
|
249
|
+
// of the cryptic "Function.prototype.call was called on undefined".
|
|
250
|
+
const _origCE = React.createElement;
|
|
251
|
+
React.createElement = function(type) {
|
|
252
|
+
if (type === undefined || type === null) {
|
|
253
|
+
console.error("[vibevibes] createElement called with undefined component. Props:", arguments[1]);
|
|
254
|
+
return _origCE("div", {
|
|
255
|
+
style: { padding: "8px", background: "#2d1b1b", border: "1px solid #ef4444",
|
|
256
|
+
borderRadius: "4px", color: "#f87171", fontSize: "12px" }
|
|
257
|
+
}, "[undefined component]");
|
|
258
|
+
}
|
|
259
|
+
return _origCE.apply(this, arguments);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
globalThis.React = React;
|
|
263
|
+
globalThis.ReactDOM = ReactDOM;
|
|
264
|
+
globalThis.Y = Y;
|
|
265
|
+
|
|
266
|
+
globalThis.z = z;
|
|
267
|
+
|
|
268
|
+
// SDK define helpers (same contract as @vibevibes/sdk)
|
|
269
|
+
globalThis.defineExperience = (config) => config;
|
|
270
|
+
globalThis.defineTool = (config) => ({ risk: "low", capabilities_required: [], ...config });
|
|
271
|
+
globalThis.defineTest = (config) => config;
|
|
272
|
+
globalThis.defineStream = (config) => config;
|
|
273
|
+
globalThis.validateExperience = () => ({ valid: true, errors: [], warnings: [] });
|
|
274
|
+
globalThis.quickTool = (name, desc, schema, handler) =>
|
|
275
|
+
globalThis.defineTool({ name, description: desc, input_schema: schema, handler });
|
|
276
|
+
globalThis.phaseTool = (zod, validPhases) => ({
|
|
277
|
+
name: "_phase.set",
|
|
278
|
+
description: "Transition to a new phase" + (validPhases && validPhases.length > 0 ? ` (${validPhases.join(", ")})` : ""),
|
|
279
|
+
input_schema: validPhases && validPhases.length > 0 ? zod.object({ phase: zod.enum(validPhases) }) : zod.object({ phase: zod.string() }),
|
|
280
|
+
risk: "low", capabilities_required: ["state.write"],
|
|
281
|
+
handler: async (ctx, input) => { ctx.setState({ ...ctx.state, phase: input.phase }); return { phase: input.phase }; },
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const { useState, useEffect, useCallback, useMemo, useRef, useContext, useReducer, createContext, forwardRef, memo } = React;
|
|
285
|
+
|
|
286
|
+
const ce = React.createElement;
|
|
287
|
+
|
|
288
|
+
// ── Sidebar + topbar ─────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
let sidebarOpen = localStorage.getItem("vibevibes_sidebar") !== "closed";
|
|
291
|
+
const activityEvents = [];
|
|
292
|
+
const MAX_ACTIVITY_EVENTS = 100;
|
|
293
|
+
let currentExperienceTitle = "";
|
|
294
|
+
|
|
295
|
+
// Apply saved sidebar state on load
|
|
296
|
+
document.getElementById("right-sidebar").classList.toggle("open", sidebarOpen);
|
|
297
|
+
|
|
298
|
+
globalThis.toggleSidebar = function() {
|
|
299
|
+
sidebarOpen = !sidebarOpen;
|
|
300
|
+
document.getElementById("right-sidebar").classList.toggle("open", sidebarOpen);
|
|
301
|
+
localStorage.setItem("vibevibes_sidebar", sidebarOpen ? "open" : "closed");
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
globalThis.switchSidebarTab = function(tab) {
|
|
305
|
+
document.querySelectorAll("#sidebar-tabs button").forEach(b => b.classList.remove("active"));
|
|
306
|
+
const buttons = document.querySelectorAll("#sidebar-tabs button");
|
|
307
|
+
buttons.forEach(b => { if (b.textContent.toLowerCase() === tab) b.classList.add("active"); });
|
|
308
|
+
document.getElementById("sidebar-chat").classList.toggle("active", tab === "chat");
|
|
309
|
+
document.getElementById("sidebar-activity").classList.toggle("active", tab === "activity");
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
globalThis.copyShareLink = function() {
|
|
313
|
+
navigator.clipboard.writeText(location.href).then(() => {
|
|
314
|
+
showToast("Link copied!", "info", 2000);
|
|
315
|
+
}).catch(() => {
|
|
316
|
+
showToast("Copy failed — use address bar", "error", 3000);
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
globalThis.resetRoom = async function() {
|
|
321
|
+
if (!ROOM_ID) return;
|
|
322
|
+
if (!confirm("Reset this experience to its initial state?")) return;
|
|
323
|
+
try {
|
|
324
|
+
const res = await fetch(`${SERVER}/rooms/${encodeURIComponent(ROOM_ID)}/reset`, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: { "Content-Type": "application/json" },
|
|
327
|
+
});
|
|
328
|
+
const data = await res.json();
|
|
329
|
+
if (data.error) { showToast(data.error); return; }
|
|
330
|
+
showToast("Experience reset", "info", 2000);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
showToast(`Reset failed: ${err.message}`);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
function kickParticipant(targetActorId) {
|
|
337
|
+
if (!confirm("Kick " + targetActorId + "?")) return;
|
|
338
|
+
if (!currentWs || currentWs.readyState !== WebSocket.OPEN) return;
|
|
339
|
+
currentWs.send(JSON.stringify({ type: "kick", targetActorId }));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseActorId(id) {
|
|
343
|
+
const m = id.match(/^(.+)-(human|ai)-(\d+)$/);
|
|
344
|
+
if (m) return { username: m[1], type: m[2] };
|
|
345
|
+
return { username: id, type: "unknown" };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function updateTopbarTitle(title) {
|
|
349
|
+
currentExperienceTitle = title || "";
|
|
350
|
+
document.getElementById("topbar-title").textContent = currentExperienceTitle;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// participantDetails: [{ actorId, type }] from server (richer than string array)
|
|
354
|
+
let _participantDetails = [];
|
|
355
|
+
let _currentActorId = null;
|
|
356
|
+
|
|
357
|
+
let _activeDropdown = null;
|
|
358
|
+
|
|
359
|
+
function closeDropdown() {
|
|
360
|
+
if (_activeDropdown) { _activeDropdown.remove(); _activeDropdown = null; }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
document.addEventListener("click", (e) => {
|
|
364
|
+
if (_activeDropdown && !_activeDropdown.contains(e.target) && !e.target.closest(".topbar-participant")) {
|
|
365
|
+
closeDropdown();
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
function showParticipantDropdown(chip, pid, type, username) {
|
|
370
|
+
closeDropdown();
|
|
371
|
+
|
|
372
|
+
const detail = _participantDetails.find(d => d.actorId === pid);
|
|
373
|
+
const myDetail = _participantDetails.find(d => d.actorId === _currentActorId);
|
|
374
|
+
const canKick = myDetail ? myDetail.type === "human" : _currentActorId && _currentActorId.startsWith("viewer");
|
|
375
|
+
const isSelf = pid === _currentActorId;
|
|
376
|
+
|
|
377
|
+
const modeColors = { behavior: "#22c55e", manual: "#3b82f6", hybrid: "#a855f7" };
|
|
378
|
+
|
|
379
|
+
const dd = document.createElement("div");
|
|
380
|
+
dd.className = "participant-dropdown";
|
|
381
|
+
|
|
382
|
+
const header = document.createElement("div");
|
|
383
|
+
header.className = "dd-header";
|
|
384
|
+
header.textContent = pid;
|
|
385
|
+
dd.appendChild(header);
|
|
386
|
+
|
|
387
|
+
// Rich metadata rows
|
|
388
|
+
if (detail) {
|
|
389
|
+
const addRow = (label, value, color) => {
|
|
390
|
+
if (!value) return;
|
|
391
|
+
const row = document.createElement("div");
|
|
392
|
+
row.style.cssText = "padding:4px 12px;font-size:11px;display:flex;justify-content:space-between;align-items:center;";
|
|
393
|
+
const lbl = document.createElement("span");
|
|
394
|
+
lbl.style.color = "#6b6b80";
|
|
395
|
+
lbl.textContent = label;
|
|
396
|
+
const val = document.createElement("span");
|
|
397
|
+
val.style.cssText = "font-weight:500;color:" + (color || "#e2e2e8") + ";";
|
|
398
|
+
if (color) {
|
|
399
|
+
val.style.cssText += "padding:1px 6px;border-radius:4px;background:" + color + "22;border:1px solid " + color + "44;font-size:10px;";
|
|
400
|
+
}
|
|
401
|
+
val.textContent = value;
|
|
402
|
+
row.appendChild(lbl);
|
|
403
|
+
row.appendChild(val);
|
|
404
|
+
dd.appendChild(row);
|
|
405
|
+
};
|
|
406
|
+
if (detail.role) addRow("Role", detail.role);
|
|
407
|
+
if (detail.agentMode) addRow("Mode", detail.agentMode, modeColors[detail.agentMode]);
|
|
408
|
+
if (detail.metadata) {
|
|
409
|
+
const model = detail.metadata.model;
|
|
410
|
+
if (model) addRow("Model", model, "#f59e0b");
|
|
411
|
+
for (const [k, v] of Object.entries(detail.metadata)) {
|
|
412
|
+
if (k === "model") continue;
|
|
413
|
+
addRow(k, v);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (canKick && !isSelf) {
|
|
419
|
+
const kickBtn = document.createElement("button");
|
|
420
|
+
kickBtn.className = "kick-action";
|
|
421
|
+
kickBtn.innerHTML = "\u{274C} Kick from room";
|
|
422
|
+
kickBtn.onclick = (e) => { e.stopPropagation(); closeDropdown(); kickParticipant(pid); };
|
|
423
|
+
dd.appendChild(kickBtn);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (isSelf) {
|
|
427
|
+
const selfLabel = document.createElement("div");
|
|
428
|
+
selfLabel.className = "dd-header";
|
|
429
|
+
selfLabel.style.borderBottom = "none";
|
|
430
|
+
selfLabel.style.color = "#4a4a5a";
|
|
431
|
+
selfLabel.style.fontStyle = "italic";
|
|
432
|
+
selfLabel.textContent = "(you)";
|
|
433
|
+
dd.appendChild(selfLabel);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
chip.appendChild(dd);
|
|
437
|
+
_activeDropdown = dd;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function updateTopbarParticipants(participants) {
|
|
441
|
+
// Close any active dropdown before clearing (prevents stale DOM reference)
|
|
442
|
+
if (_activeDropdown) { closeDropdown(); }
|
|
443
|
+
const container = document.getElementById("topbar-participants");
|
|
444
|
+
container.innerHTML = "";
|
|
445
|
+
const modeBorderColors = { behavior: "#22c55e", manual: "#3b82f6", hybrid: "#a855f7" };
|
|
446
|
+
for (const pid of participants) {
|
|
447
|
+
// Prefer participantDetails for type resolution (actorId format no longer encodes type)
|
|
448
|
+
const detail = _participantDetails.find(d => d.actorId === pid);
|
|
449
|
+
const rawType = detail ? detail.type : parseActorId(pid).type;
|
|
450
|
+
const type = ['human', 'ai', 'unknown'].includes(rawType) ? rawType : 'unknown';
|
|
451
|
+
const { username } = parseActorId(pid);
|
|
452
|
+
const chip = document.createElement("span");
|
|
453
|
+
chip.className = `topbar-participant ${type}`;
|
|
454
|
+
// Show role as suffix if present
|
|
455
|
+
const roleSuffix = detail && detail.role ? ` [${detail.role}]` : "";
|
|
456
|
+
chip.textContent = (type === "ai" ? "\u{1F916}" : "") + username + roleSuffix;
|
|
457
|
+
// Color-code border by agentMode for AI participants
|
|
458
|
+
if (detail && detail.agentMode && modeBorderColors[detail.agentMode]) {
|
|
459
|
+
chip.style.borderColor = modeBorderColors[detail.agentMode];
|
|
460
|
+
}
|
|
461
|
+
chip.title = pid + (detail && detail.agentMode ? ` (${detail.agentMode})` : "");
|
|
462
|
+
chip.onclick = (e) => { e.stopPropagation(); showParticipantDropdown(chip, pid, type, username); };
|
|
463
|
+
container.appendChild(chip);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function updateParticipantList(participants) {
|
|
468
|
+
// Sidebar participant list (detailed)
|
|
469
|
+
const el = document.getElementById("participant-list");
|
|
470
|
+
const countEl = document.getElementById("participant-count");
|
|
471
|
+
countEl.textContent = String(participants.length);
|
|
472
|
+
el.innerHTML = "";
|
|
473
|
+
|
|
474
|
+
const myDetail = _participantDetails.find(d => d.actorId === _currentActorId);
|
|
475
|
+
const canKick = myDetail ? myDetail.type === "human" : _currentActorId && _currentActorId.startsWith("viewer");
|
|
476
|
+
|
|
477
|
+
for (const pid of participants) {
|
|
478
|
+
const detail = _participantDetails.find(d => d.actorId === pid);
|
|
479
|
+
const rawType2 = detail ? detail.type : parseActorId(pid).type;
|
|
480
|
+
const type = ['human', 'ai', 'unknown'].includes(rawType2) ? rawType2 : 'unknown';
|
|
481
|
+
const { username } = parseActorId(pid);
|
|
482
|
+
const chip = document.createElement("span");
|
|
483
|
+
chip.className = `participant-chip ${type}`;
|
|
484
|
+
|
|
485
|
+
const nameSpan = document.createElement("span");
|
|
486
|
+
nameSpan.textContent = (type === "ai" ? "\u{1F916} " : "") + username;
|
|
487
|
+
chip.appendChild(nameSpan);
|
|
488
|
+
|
|
489
|
+
// Show role as sub-label
|
|
490
|
+
if (detail && detail.role) {
|
|
491
|
+
const roleSpan = document.createElement("span");
|
|
492
|
+
roleSpan.style.cssText = "font-size:9px;color:#6b6b80;margin-left:4px;";
|
|
493
|
+
roleSpan.textContent = detail.role;
|
|
494
|
+
chip.appendChild(roleSpan);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
chip.title = pid;
|
|
498
|
+
|
|
499
|
+
if (canKick && pid !== _currentActorId) {
|
|
500
|
+
const btn = document.createElement("button");
|
|
501
|
+
btn.className = "kick-btn";
|
|
502
|
+
btn.title = "Kick " + username;
|
|
503
|
+
btn.textContent = "\u00D7";
|
|
504
|
+
btn.onclick = (e) => { e.stopPropagation(); kickParticipant(pid); };
|
|
505
|
+
chip.appendChild(btn);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
el.appendChild(chip);
|
|
509
|
+
}
|
|
510
|
+
// Also update topbar compact chips
|
|
511
|
+
updateTopbarParticipants(participants);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function addActivityEvent(event) {
|
|
515
|
+
activityEvents.push(event);
|
|
516
|
+
if (activityEvents.length > MAX_ACTIVITY_EVENTS) {
|
|
517
|
+
activityEvents.shift();
|
|
518
|
+
// Remove oldest DOM node to prevent unbounded growth
|
|
519
|
+
const log = document.getElementById("event-log");
|
|
520
|
+
if (log && log.firstChild) log.removeChild(log.firstChild);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const log = document.getElementById("event-log");
|
|
524
|
+
const countEl = document.getElementById("event-count");
|
|
525
|
+
countEl.textContent = String(activityEvents.length);
|
|
526
|
+
|
|
527
|
+
const entry = document.createElement("div");
|
|
528
|
+
entry.className = "event-entry";
|
|
529
|
+
|
|
530
|
+
const { type: actorType } = parseActorId(event.actorId || "unknown");
|
|
531
|
+
const time = new Date(event.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
532
|
+
|
|
533
|
+
entry.innerHTML = `<span class="event-time">${escHtml(time)}</span>`
|
|
534
|
+
+ `<span class="event-actor ${escHtml(actorType)}">${escHtml(event.actorId)}</span> `
|
|
535
|
+
+ `<span class="event-tool">${escHtml(event.tool)}</span>`
|
|
536
|
+
+ (event.error ? ` <span class="event-error">ERR</span>` : "");
|
|
537
|
+
|
|
538
|
+
log.appendChild(entry);
|
|
539
|
+
log.scrollTop = log.scrollHeight;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function renderSidebarChat(sharedState, callTool, actorId) {
|
|
543
|
+
const el = document.getElementById("sidebar-chat");
|
|
544
|
+
if (!el) return;
|
|
545
|
+
const msgs = sharedState._chat || [];
|
|
546
|
+
const parseActor = (id) => {
|
|
547
|
+
const m = id.match(/^(.+)-(human|ai)-(\d+)$/);
|
|
548
|
+
return m ? { name: m[1], type: m[2] } : { name: id, type: "unknown" };
|
|
549
|
+
};
|
|
550
|
+
const colors = { human: "#60a5fa", ai: "#a78bfa", unknown: "#94a3b8" };
|
|
551
|
+
el.innerHTML = `
|
|
552
|
+
<div style="flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column;gap:6px">
|
|
553
|
+
${msgs.length === 0 ? '<div style="color:#4a4a5a;font-size:13px;text-align:center;padding:32px 0">No messages yet</div>' :
|
|
554
|
+
msgs.map(m => {
|
|
555
|
+
const a = parseActor(m.actorId);
|
|
556
|
+
const isMe = m.actorId === actorId;
|
|
557
|
+
return `<div style="display:flex;flex-direction:column;align-items:${isMe ? 'flex-end' : 'flex-start'}">
|
|
558
|
+
<span style="font-size:11px;font-weight:600;color:${colors[a.type] || colors.unknown}">${escHtml(a.name)}</span>
|
|
559
|
+
<div style="background:${isMe ? '#6366f1' : '#1e1e2e'};color:${isMe ? '#fff' : '#e2e2e8'};padding:6px 10px;border-radius:10px;font-size:13px;max-width:240px;word-break:break-word">${escHtml(m.message)}</div>
|
|
560
|
+
</div>`;
|
|
561
|
+
}).join("")}
|
|
562
|
+
</div>
|
|
563
|
+
<div style="padding:8px 12px;border-top:1px solid #1e1e24;display:flex;gap:8px;flex-shrink:0">
|
|
564
|
+
<input id="chat-input" type="text" placeholder="Type a message..." style="flex:1;padding:6px 10px;font-size:13px;border:1px solid #334155;border-radius:6px;background:#1e293b;color:#fff;outline:none;font-family:system-ui">
|
|
565
|
+
<button id="chat-send" style="padding:6px 12px;border-radius:6px;background:#6366f1;color:#fff;border:none;font-size:13px;cursor:pointer;font-weight:500">Send</button>
|
|
566
|
+
</div>`;
|
|
567
|
+
const inp = el.querySelector("#chat-input");
|
|
568
|
+
const btn = el.querySelector("#chat-send");
|
|
569
|
+
const send = () => {
|
|
570
|
+
const text = inp.value.trim();
|
|
571
|
+
if (!text) return;
|
|
572
|
+
inp.value = "";
|
|
573
|
+
callTool("_chat.send", { message: text }).catch(() => {});
|
|
574
|
+
};
|
|
575
|
+
btn.onclick = send;
|
|
576
|
+
inp.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); send(); } };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── Toast notifications ─────────────────────────────────
|
|
580
|
+
|
|
581
|
+
function showToast(message, type = "error", durationMs = 6000) {
|
|
582
|
+
const container = document.getElementById("toast-container");
|
|
583
|
+
const toast = document.createElement("div");
|
|
584
|
+
const typeClass = type === "build" ? "toast-build" : type === "info" ? "toast-info" : "";
|
|
585
|
+
toast.className = "toast" + (typeClass ? " " + typeClass : "");
|
|
586
|
+
toast.textContent = message;
|
|
587
|
+
toast.onclick = () => toast.remove();
|
|
588
|
+
container.appendChild(toast);
|
|
589
|
+
setTimeout(() => { if (toast.parentNode) toast.remove(); }, durationMs);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── Connect to the experience ─────────────────────────────
|
|
593
|
+
|
|
594
|
+
const SERVER = location.origin;
|
|
595
|
+
const WS_PROTO = location.protocol === "https:" ? "wss:" : "ws:";
|
|
596
|
+
const WS_URL = `${WS_PROTO}//${location.host}`;
|
|
597
|
+
|
|
598
|
+
// Room-aware: read room ID from URL path (e.g. /room/room-abc123)
|
|
599
|
+
function getRoomIdFromPath() {
|
|
600
|
+
const path = location.pathname.replace(/^\/+/, "");
|
|
601
|
+
// /room/<roomId> → extract roomId
|
|
602
|
+
const roomMatch = path.match(/^room\/(.+)$/);
|
|
603
|
+
if (roomMatch) {
|
|
604
|
+
try { return decodeURIComponent(roomMatch[1]); } catch { return roomMatch[1]; }
|
|
605
|
+
}
|
|
606
|
+
// Bare path (legacy) — treat as room ID if non-empty
|
|
607
|
+
return path || null;
|
|
608
|
+
}
|
|
609
|
+
let ROOM_ID = getRoomIdFromPath();
|
|
610
|
+
// Sanitize ROOM_ID to prevent path traversal in URL construction
|
|
611
|
+
if (ROOM_ID && !/^[a-zA-Z0-9_\-]+$/.test(ROOM_ID)) ROOM_ID = null;
|
|
612
|
+
let currentWs = null;
|
|
613
|
+
|
|
614
|
+
// Read ?role= query param (e.g. ?role=spectator)
|
|
615
|
+
function getRoleFromQuery() {
|
|
616
|
+
const params = new URLSearchParams(location.search);
|
|
617
|
+
return params.get("role") || undefined;
|
|
618
|
+
}
|
|
619
|
+
let REQUESTED_ROLE = getRoleFromQuery();
|
|
620
|
+
|
|
621
|
+
// ── Browser error reporting to agent ─────────────────────
|
|
622
|
+
// Sends errors to the server so the agent sees them via /agent-context
|
|
623
|
+
function reportBrowserError(msg) {
|
|
624
|
+
try {
|
|
625
|
+
fetch("/browser-error", {
|
|
626
|
+
method: "POST",
|
|
627
|
+
headers: { "Content-Type": "application/json" },
|
|
628
|
+
body: JSON.stringify({ message: String(msg), roomId: ROOM_ID || "local" }),
|
|
629
|
+
}).catch(() => {}); // fire-and-forget
|
|
630
|
+
} catch {}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Capture unhandled errors and unhandled promise rejections
|
|
634
|
+
window.addEventListener("error", (e) => {
|
|
635
|
+
reportBrowserError(e.message || String(e));
|
|
636
|
+
});
|
|
637
|
+
window.addEventListener("unhandledrejection", (e) => {
|
|
638
|
+
reportBrowserError(e.reason?.message || String(e.reason));
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
function showError(msg) {
|
|
642
|
+
document.getElementById("loading").style.display = "none";
|
|
643
|
+
document.getElementById("library").className = "";
|
|
644
|
+
document.getElementById("main-wrapper").style.display = "none";
|
|
645
|
+
document.getElementById("error-display").style.display = "block";
|
|
646
|
+
document.getElementById("error-message").textContent = msg;
|
|
647
|
+
reportBrowserError(msg);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── Library / Navigation ─────────────────────────────────
|
|
651
|
+
|
|
652
|
+
function showLibraryView() {
|
|
653
|
+
document.getElementById("library").className = "active";
|
|
654
|
+
document.getElementById("loading").style.display = "none";
|
|
655
|
+
document.getElementById("main-wrapper").style.display = "none";
|
|
656
|
+
document.getElementById("error-display").style.display = "none";
|
|
657
|
+
// Hide sidebar controls in library view
|
|
658
|
+
document.getElementById("right-sidebar").classList.remove("open");
|
|
659
|
+
document.getElementById("btn-reset").style.display = "none";
|
|
660
|
+
document.getElementById("btn-share").style.display = "none";
|
|
661
|
+
document.getElementById("btn-sidebar").style.display = "none";
|
|
662
|
+
document.getElementById("topbar-participants").style.display = "none";
|
|
663
|
+
updateTopbarTitle("Library");
|
|
664
|
+
loadLibrary();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function showRoomView() {
|
|
668
|
+
document.getElementById("library").className = "";
|
|
669
|
+
document.getElementById("main-wrapper").style.display = "flex";
|
|
670
|
+
document.getElementById("root").style.display = "block";
|
|
671
|
+
// Show sidebar controls in room view
|
|
672
|
+
document.getElementById("btn-reset").style.display = "flex";
|
|
673
|
+
document.getElementById("btn-share").style.display = "flex";
|
|
674
|
+
document.getElementById("btn-sidebar").style.display = "flex";
|
|
675
|
+
document.getElementById("topbar-participants").style.display = "flex";
|
|
676
|
+
if (sidebarOpen) document.getElementById("right-sidebar").classList.add("open");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
globalThis.navigateToLibrary = function() {
|
|
680
|
+
if (currentWs) { try { currentWs.close(); } catch {} currentWs = null; }
|
|
681
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
682
|
+
// Unmount Canvas tree so useEffect cleanups run (stops audio, disposes engines)
|
|
683
|
+
if (reactRoot) { try { reactRoot.render(null); } catch {} reactRoot = null; }
|
|
684
|
+
// Reset stale state from previous room
|
|
685
|
+
reconnectAttempts = 0;
|
|
686
|
+
activityEvents.length = 0;
|
|
687
|
+
_participantDetails = [];
|
|
688
|
+
_currentActorId = null;
|
|
689
|
+
const eventLogEl = document.getElementById("event-log");
|
|
690
|
+
if (eventLogEl) eventLogEl.innerHTML = "";
|
|
691
|
+
history.pushState({}, "", "/");
|
|
692
|
+
ROOM_ID = null;
|
|
693
|
+
showLibraryView();
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
function navigateToRoom(roomId, role, skipPush) {
|
|
697
|
+
if (currentWs) { try { currentWs.close(); } catch {} currentWs = null; }
|
|
698
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
699
|
+
// Unmount React trees to prevent stale closures
|
|
700
|
+
if (reactRoot) { try { reactRoot.render(null); } catch {} reactRoot = null; }
|
|
701
|
+
// Reset stale state from previous room
|
|
702
|
+
reconnectAttempts = 0;
|
|
703
|
+
activityEvents.length = 0;
|
|
704
|
+
_participantDetails = [];
|
|
705
|
+
_currentActorId = null;
|
|
706
|
+
const eventLogEl2 = document.getElementById("event-log");
|
|
707
|
+
if (eventLogEl2) eventLogEl2.innerHTML = "";
|
|
708
|
+
sessionStorage.removeItem("vibevibes_actorId"); // Clear stale actorId from previous room
|
|
709
|
+
sessionStorage.removeItem("vibevibes_viewerSessionId"); // New room = new identity
|
|
710
|
+
ROOM_ID = roomId;
|
|
711
|
+
REQUESTED_ROLE = role || getRoleFromQuery();
|
|
712
|
+
const qs = REQUESTED_ROLE ? `?role=${encodeURIComponent(REQUESTED_ROLE)}` : "";
|
|
713
|
+
if (!skipPush) history.pushState({}, "", `/room/${encodeURIComponent(roomId)}${qs}`);
|
|
714
|
+
showRoomView();
|
|
715
|
+
document.getElementById("loading").style.display = "flex";
|
|
716
|
+
updateTopbarTitle(roomId); // Will be replaced by experience title once bundle loads
|
|
717
|
+
start().catch((err) => showError("Failed to start: " + (err.message || err)));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Handle browser back/forward — skipPush=true to avoid pushState inside popstate
|
|
721
|
+
window.addEventListener("popstate", () => {
|
|
722
|
+
const r = getRoomIdFromPath();
|
|
723
|
+
if (r) { navigateToRoom(r, null, true); }
|
|
724
|
+
else { if (currentWs) { try { currentWs.close(); } catch {} currentWs = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (reactRoot) { try { reactRoot.render(null); } catch {} reactRoot = null; } ROOM_ID = null; reconnectAttempts = 0; activityEvents.length = 0; _participantDetails = []; _currentActorId = null; sessionStorage.removeItem("vibevibes_actorId"); sessionStorage.removeItem("vibevibes_viewerSessionId"); const el = document.getElementById("event-log"); if (el) el.innerHTML = ""; showLibraryView(); }
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
async function spawnAndNavigate(experienceId) {
|
|
728
|
+
try {
|
|
729
|
+
const res = await fetch(`${SERVER}/rooms/spawn`, {
|
|
730
|
+
method: "POST",
|
|
731
|
+
headers: { "Content-Type": "application/json" },
|
|
732
|
+
body: JSON.stringify({ experienceId, sourceRoomId: "library" }),
|
|
733
|
+
});
|
|
734
|
+
const data = await res.json();
|
|
735
|
+
if (data.error) { showToast(data.error); return; }
|
|
736
|
+
// Validate roomId to prevent path traversal
|
|
737
|
+
if (!data.roomId || !/^[a-zA-Z0-9_\-]+$/.test(data.roomId)) { showToast("Invalid room ID from server"); return; }
|
|
738
|
+
navigateToRoom(data.roomId);
|
|
739
|
+
} catch (err) {
|
|
740
|
+
showToast(`Failed to spawn: ${err.message}`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
globalThis.spawnAndNavigate = spawnAndNavigate;
|
|
744
|
+
|
|
745
|
+
let _libClickHandler = null;
|
|
746
|
+
async function loadLibrary() {
|
|
747
|
+
const lib = document.getElementById("library");
|
|
748
|
+
lib.innerHTML = '<h2>Library</h2><div style="color:#6b6b80;font-size:13px;">Loading...</div>';
|
|
749
|
+
|
|
750
|
+
// Remove previous listener to prevent accumulation on repeated calls
|
|
751
|
+
if (_libClickHandler) {
|
|
752
|
+
lib.removeEventListener("click", _libClickHandler);
|
|
753
|
+
_libClickHandler = null;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
const experiences = await fetch(`${SERVER}/experiences`).then(r => r.json());
|
|
758
|
+
|
|
759
|
+
// Filter out host experience — host is the homepage, not a playable experience
|
|
760
|
+
const templateExperiences = experiences.filter(exp => exp.source !== "host");
|
|
761
|
+
|
|
762
|
+
let html = '<h2>Library</h2>';
|
|
763
|
+
|
|
764
|
+
if (templateExperiences.length === 0) {
|
|
765
|
+
html += '<div class="lib-empty">No experiences registered.</div>';
|
|
766
|
+
} else {
|
|
767
|
+
for (const exp of templateExperiences) {
|
|
768
|
+
// Each experience has a pre-created room with id = experienceId
|
|
769
|
+
html += `<div class="lib-card" data-room-id="${escHtml(exp.id)}">`;
|
|
770
|
+
html += `<div class="title">${escHtml(exp.title)}</div>
|
|
771
|
+
<div class="desc">${escHtml(exp.description)}</div>
|
|
772
|
+
<div class="meta">
|
|
773
|
+
<span>${escHtml(exp.id)}</span>
|
|
774
|
+
<span>v${escHtml(exp.version)}</span>
|
|
775
|
+
</div>
|
|
776
|
+
</div>`;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
lib.innerHTML = html;
|
|
781
|
+
// Use event delegation instead of onclick attributes (prevents XSS via room IDs)
|
|
782
|
+
_libClickHandler = (e) => {
|
|
783
|
+
const card = e.target.closest(".lib-card[data-room-id]");
|
|
784
|
+
if (card) navigateToRoom(card.dataset.roomId);
|
|
785
|
+
};
|
|
786
|
+
lib.addEventListener("click", _libClickHandler);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
lib.innerHTML = `<h2>Library</h2><div class="lib-empty">Failed to load: ${escHtml(err.message)}</div>`;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function escHtml(s) {
|
|
793
|
+
if (s == null || s === '') return '';
|
|
794
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Expose navigateToRoom globally for onclick handlers
|
|
798
|
+
globalThis.navigateToRoom = navigateToRoom;
|
|
799
|
+
|
|
800
|
+
// Unique viewer session ID per tab (survives refresh via sessionStorage, unique across tabs)
|
|
801
|
+
function getViewerSessionId() {
|
|
802
|
+
let id = sessionStorage.getItem("vibevibes_viewerSessionId");
|
|
803
|
+
if (!id) {
|
|
804
|
+
id = "viewer-" + Math.random().toString(36).slice(2, 10) + "-" + Date.now().toString(36);
|
|
805
|
+
sessionStorage.setItem("vibevibes_viewerSessionId", id);
|
|
806
|
+
}
|
|
807
|
+
return id;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function start() {
|
|
811
|
+
const roomId = ROOM_ID || "local";
|
|
812
|
+
const viewerOwner = getViewerSessionId();
|
|
813
|
+
try {
|
|
814
|
+
// Connect WebSocket
|
|
815
|
+
const ws = new WebSocket(WS_URL);
|
|
816
|
+
currentWs = ws;
|
|
817
|
+
|
|
818
|
+
let actorId = "viewer";
|
|
819
|
+
let sharedState = {};
|
|
820
|
+
let _stateVersion = 0;
|
|
821
|
+
let participants = [];
|
|
822
|
+
let roomConfig = {};
|
|
823
|
+
let myRole = REQUESTED_ROLE; // "spectator" | "player" | undefined
|
|
824
|
+
let Canvas = null;
|
|
825
|
+
let EntryRitual = null;
|
|
826
|
+
let ritualComplete = false;
|
|
827
|
+
let hotReloadKey = 0;
|
|
828
|
+
// ── Optimistic state layer ──────────────────────────────
|
|
829
|
+
// Tracks in-flight optimistic overlays keyed by a unique call ID.
|
|
830
|
+
// Each overlay is { state: {...}, ts: number }.
|
|
831
|
+
// Display state = sharedState merged with all active overlays.
|
|
832
|
+
let optimisticOverlays = new Map();
|
|
833
|
+
let optimisticSeq = 0;
|
|
834
|
+
|
|
835
|
+
function getDisplayState() {
|
|
836
|
+
if (optimisticOverlays.size === 0) return sharedState;
|
|
837
|
+
let merged = { ...sharedState };
|
|
838
|
+
for (const overlay of optimisticOverlays.values()) {
|
|
839
|
+
Object.assign(merged, overlay.state);
|
|
840
|
+
}
|
|
841
|
+
return merged;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ── Ephemeral state (high-frequency, no tool gate) ──────
|
|
845
|
+
let ephemeralState = {};
|
|
846
|
+
|
|
847
|
+
function setEphemeral(data) {
|
|
848
|
+
ephemeralState = {
|
|
849
|
+
...ephemeralState,
|
|
850
|
+
[actorId]: { ...(ephemeralState[actorId] || {}), ...data },
|
|
851
|
+
};
|
|
852
|
+
// Broadcast to server for relay to other participants
|
|
853
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
854
|
+
ws.send(JSON.stringify({ type: "ephemeral", actorId, data }));
|
|
855
|
+
}
|
|
856
|
+
render();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ── Stream function (high-frequency, bypasses tool gate) ──────
|
|
860
|
+
function streamFn(name, input) {
|
|
861
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
862
|
+
ws.send(JSON.stringify({ type: "stream", name, input, actorId }));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Spectators get a no-op callTool that logs a warning
|
|
867
|
+
async function spectatorCallTool(name, input) {
|
|
868
|
+
console.warn(`[spectator] Blocked tool call: ${name}`, input);
|
|
869
|
+
return { blocked: true, reason: "spectators cannot call tools" };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function render() {
|
|
873
|
+
if (!Canvas || !reactRoot) return;
|
|
874
|
+
|
|
875
|
+
const displayState = getDisplayState();
|
|
876
|
+
const isSpectator = myRole === "spectator";
|
|
877
|
+
const activeCallTool = isSpectator ? spectatorCallTool : callTool;
|
|
878
|
+
|
|
879
|
+
// Expose context for hooks
|
|
880
|
+
globalThis.__vibevibes_ctx__ = {
|
|
881
|
+
sharedState: displayState,
|
|
882
|
+
callTool: activeCallTool,
|
|
883
|
+
participants,
|
|
884
|
+
actorId,
|
|
885
|
+
ephemeralState,
|
|
886
|
+
setEphemeral,
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
// Entry ritual gate: show ritual component before Canvas
|
|
890
|
+
if (EntryRitual && !ritualComplete) {
|
|
891
|
+
reactRoot.render(
|
|
892
|
+
ce(CanvasErrorBoundary, { key: hotReloadKey },
|
|
893
|
+
ce(EntryRitual, {
|
|
894
|
+
actorId, participants, participantDetails: _participantDetails,
|
|
895
|
+
callTool: activeCallTool,
|
|
896
|
+
proceed: function() { ritualComplete = true; render(); },
|
|
897
|
+
roomConfig,
|
|
898
|
+
})
|
|
899
|
+
)
|
|
900
|
+
);
|
|
901
|
+
renderSidebarChat(displayState, callTool, actorId);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
reactRoot.render(
|
|
906
|
+
ce(CanvasErrorBoundary, { key: hotReloadKey },
|
|
907
|
+
ce(Canvas, {
|
|
908
|
+
roomId: roomId,
|
|
909
|
+
actorId,
|
|
910
|
+
sharedState: displayState,
|
|
911
|
+
callTool: activeCallTool,
|
|
912
|
+
participants,
|
|
913
|
+
participantDetails: _participantDetails,
|
|
914
|
+
ephemeralState,
|
|
915
|
+
setEphemeral,
|
|
916
|
+
roomConfig,
|
|
917
|
+
stream: streamFn,
|
|
918
|
+
role: myRole || "player",
|
|
919
|
+
})
|
|
920
|
+
)
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
// Render ChatPanel in sidebar
|
|
924
|
+
renderSidebarChat(displayState, callTool, actorId);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function callTool(name, input, _optimisticState) {
|
|
928
|
+
// Validate tool name to prevent path traversal (e.g. "../leave")
|
|
929
|
+
if (!/^[a-zA-Z0-9_.\-:]+$/.test(name)) { throw new Error("Invalid tool name"); }
|
|
930
|
+
// If an optimistic state is provided, apply it IMMEDIATELY
|
|
931
|
+
let overlayId = null;
|
|
932
|
+
if (_optimisticState) {
|
|
933
|
+
overlayId = ++optimisticSeq;
|
|
934
|
+
optimisticOverlays.set(overlayId, { state: _optimisticState, ts: Date.now() });
|
|
935
|
+
render(); // Re-render with predicted state at T=0ms
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
try {
|
|
939
|
+
const toolUrl = roomId === "local"
|
|
940
|
+
? `${SERVER}/tools/${name}`
|
|
941
|
+
: `${SERVER}/rooms/${roomId}/tools/${name}`;
|
|
942
|
+
const res = await fetch(toolUrl, {
|
|
943
|
+
method: "POST",
|
|
944
|
+
headers: { "Content-Type": "application/json" },
|
|
945
|
+
body: JSON.stringify({ actorId, input: input || {} }),
|
|
946
|
+
});
|
|
947
|
+
const data = await res.json();
|
|
948
|
+
if (data.error) {
|
|
949
|
+
showToast(data.error);
|
|
950
|
+
throw new Error(data.error);
|
|
951
|
+
}
|
|
952
|
+
return data.output;
|
|
953
|
+
} catch (err) {
|
|
954
|
+
if (!document.querySelector(".toast")) {
|
|
955
|
+
showToast(`Tool '${name}' failed: ${err.message || "unknown error"}`);
|
|
956
|
+
}
|
|
957
|
+
throw err;
|
|
958
|
+
} finally {
|
|
959
|
+
// Clear this overlay — server state broadcast will take over
|
|
960
|
+
if (overlayId !== null) {
|
|
961
|
+
optimisticOverlays.delete(overlayId);
|
|
962
|
+
render(); // Re-render with server-authoritative state
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Error boundary component
|
|
968
|
+
function getErrorHint(message) {
|
|
969
|
+
if (!message) return null;
|
|
970
|
+
if (message.includes("Cannot read properties of undefined") || message.includes("Cannot read property"))
|
|
971
|
+
return "A component accessed state that hasn't been initialized. Check your initialState and ensure all keys exist before reading them.";
|
|
972
|
+
if (message.includes("is not a function"))
|
|
973
|
+
return "Something expected to be a function is not. Check for missing imports or incorrect prop types.";
|
|
974
|
+
if (message.includes("Maximum update depth"))
|
|
975
|
+
return "Infinite re-render loop detected. Check useEffect dependencies — avoid setting state unconditionally inside useEffect.";
|
|
976
|
+
if (message.includes("Invalid hook call"))
|
|
977
|
+
return "Hooks can only be called inside React function components. Check that you're not calling useState/useEffect outside a component.";
|
|
978
|
+
if (message.includes("is not defined"))
|
|
979
|
+
return "A variable or import is missing. Check that all imports from @vibevibes/sdk or other modules are correct.";
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
class CanvasErrorBoundary extends React.Component {
|
|
984
|
+
constructor(props) {
|
|
985
|
+
super(props);
|
|
986
|
+
this.state = { error: null, componentStack: null };
|
|
987
|
+
}
|
|
988
|
+
static getDerivedStateFromError(error) { return { error }; }
|
|
989
|
+
componentDidCatch(error, info) {
|
|
990
|
+
console.error("[viewer] Experience crashed:", error, info?.componentStack);
|
|
991
|
+
if (info?.componentStack) this.setState({ componentStack: info.componentStack });
|
|
992
|
+
reportBrowserError(`[Canvas crash] ${error.message || error}`);
|
|
993
|
+
}
|
|
994
|
+
render() {
|
|
995
|
+
if (this.state.error) {
|
|
996
|
+
const msg = this.state.error.message || String(this.state.error);
|
|
997
|
+
const hint = getErrorHint(msg);
|
|
998
|
+
const stack = this.state.componentStack;
|
|
999
|
+
const fullError = msg + (stack ? "\n\nComponent stack:" + stack : "");
|
|
1000
|
+
|
|
1001
|
+
return ce("div", {
|
|
1002
|
+
style: { padding: "32px", textAlign: "center", color: "#ef4444", maxWidth: "640px", margin: "0 auto" },
|
|
1003
|
+
},
|
|
1004
|
+
ce("h2", null, "Experience Crashed"),
|
|
1005
|
+
ce("pre", { style: { fontSize: "13px", color: "#94a3b8", marginTop: "12px", whiteSpace: "pre-wrap", textAlign: "left" } }, msg),
|
|
1006
|
+
hint ? ce("div", {
|
|
1007
|
+
style: { marginTop: "12px", padding: "12px 16px", background: "#1e293b", borderRadius: "8px", border: "1px solid #334155", textAlign: "left" },
|
|
1008
|
+
},
|
|
1009
|
+
ce("strong", { style: { color: "#fbbf24", fontSize: "13px" } }, "Hint: "),
|
|
1010
|
+
ce("span", { style: { color: "#cbd5e1", fontSize: "13px" } }, hint),
|
|
1011
|
+
) : null,
|
|
1012
|
+
stack ? ce("details", { style: { marginTop: "12px", textAlign: "left" } },
|
|
1013
|
+
ce("summary", { style: { color: "#64748b", cursor: "pointer", fontSize: "12px" } }, "Component stack"),
|
|
1014
|
+
ce("pre", { style: { fontSize: "11px", color: "#64748b", marginTop: "4px", whiteSpace: "pre-wrap" } }, stack),
|
|
1015
|
+
) : null,
|
|
1016
|
+
ce("div", { style: { marginTop: "16px", display: "flex", gap: "8px", justifyContent: "center" } },
|
|
1017
|
+
ce("button", {
|
|
1018
|
+
onClick: () => this.setState({ error: null, componentStack: null }),
|
|
1019
|
+
style: { padding: "8px 16px", background: "#6366f1", color: "#fff", border: "none", borderRadius: "6px", cursor: "pointer" },
|
|
1020
|
+
}, "Try Again"),
|
|
1021
|
+
ce("button", {
|
|
1022
|
+
onClick: () => { navigator.clipboard.writeText(fullError); showToast("Error copied to clipboard"); },
|
|
1023
|
+
style: { padding: "8px 16px", background: "#334155", color: "#cbd5e1", border: "none", borderRadius: "6px", cursor: "pointer" },
|
|
1024
|
+
}, "Copy Error"),
|
|
1025
|
+
),
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
return this.props.children;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
ws.onopen = () => {
|
|
1033
|
+
reconnectAttempts = 0; // Reset backoff on successful connection
|
|
1034
|
+
const savedActorId = sessionStorage.getItem("vibevibes_actorId");
|
|
1035
|
+
ws.send(JSON.stringify({ type: "join", username: "viewer", roomId: roomId, actorId: savedActorId, owner: viewerOwner, role: REQUESTED_ROLE }));
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
ws.onmessage = async (event) => {
|
|
1039
|
+
if (currentWs !== ws) return; // Guard against stale WS after navigation
|
|
1040
|
+
let msg;
|
|
1041
|
+
try { msg = JSON.parse(event.data); } catch { return; }
|
|
1042
|
+
|
|
1043
|
+
if (msg.type === "error") {
|
|
1044
|
+
// Server rejected the join (e.g., stale session). Clear saved actorId and retry.
|
|
1045
|
+
console.warn("[viewer] Server error:", msg.error);
|
|
1046
|
+
const savedId = sessionStorage.getItem("vibevibes_actorId");
|
|
1047
|
+
if (savedId) {
|
|
1048
|
+
sessionStorage.removeItem("vibevibes_actorId");
|
|
1049
|
+
console.log("[viewer] Cleared stale actorId, retrying join...");
|
|
1050
|
+
ws.send(JSON.stringify({ type: "join", username: "viewer", roomId: roomId, owner: viewerOwner, role: REQUESTED_ROLE }));
|
|
1051
|
+
} else {
|
|
1052
|
+
showError(`Server error: ${msg.error}`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (msg.type === "joined") {
|
|
1057
|
+
actorId = msg.actorId;
|
|
1058
|
+
_currentActorId = actorId;
|
|
1059
|
+
sessionStorage.setItem("vibevibes_actorId", actorId);
|
|
1060
|
+
sharedState = msg.sharedState || {};
|
|
1061
|
+
_stateVersion = msg.stateVersion ?? 0;
|
|
1062
|
+
participants = msg.participants || [];
|
|
1063
|
+
if (msg.participantDetails) _participantDetails = msg.participantDetails;
|
|
1064
|
+
roomConfig = msg.config || {};
|
|
1065
|
+
if (msg.role) myRole = msg.role;
|
|
1066
|
+
|
|
1067
|
+
// Load client bundle with retry (room-aware: external experiences get their own bundle)
|
|
1068
|
+
const bundleUrl = roomId === "local"
|
|
1069
|
+
? `${SERVER}/bundle?_t=${Date.now()}`
|
|
1070
|
+
: `${SERVER}/rooms/${roomId}/bundle?_t=${Date.now()}`;
|
|
1071
|
+
let bundleLoaded = false;
|
|
1072
|
+
for (let attempt = 0; attempt < 3 && !bundleLoaded; attempt++) {
|
|
1073
|
+
try {
|
|
1074
|
+
if (attempt > 0) await new Promise(r => setTimeout(r, 500 * attempt));
|
|
1075
|
+
const bundleRes = await fetch(bundleUrl + "&r=" + attempt);
|
|
1076
|
+
if (!bundleRes.ok) throw new Error(`Bundle fetch failed: ${bundleRes.status} ${bundleRes.statusText}`);
|
|
1077
|
+
const bundleCode = await bundleRes.text();
|
|
1078
|
+
|
|
1079
|
+
// Protocol experience: empty bundle → load HTML canvas in iframe
|
|
1080
|
+
if (!bundleCode.trim()) {
|
|
1081
|
+
const canvasUrl = `${SERVER}/rooms/${roomId}/canvas?actorId=${encodeURIComponent(actorId)}&roomId=${encodeURIComponent(roomId)}`;
|
|
1082
|
+
const root = document.getElementById("root");
|
|
1083
|
+
root.innerHTML = "";
|
|
1084
|
+
const iframe = document.createElement("iframe");
|
|
1085
|
+
iframe.src = canvasUrl;
|
|
1086
|
+
iframe.style.cssText = "width:100%;height:100%;border:none;";
|
|
1087
|
+
root.appendChild(iframe);
|
|
1088
|
+
Canvas = null;
|
|
1089
|
+
updateTopbarTitle(roomId);
|
|
1090
|
+
document.getElementById("loading").style.display = "none";
|
|
1091
|
+
showRoomView();
|
|
1092
|
+
bundleLoaded = true;
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const blob = new Blob([bundleCode], { type: "text/javascript" });
|
|
1097
|
+
const url = URL.createObjectURL(blob);
|
|
1098
|
+
const mod = await import(url);
|
|
1099
|
+
URL.revokeObjectURL(url);
|
|
1100
|
+
|
|
1101
|
+
const experience = mod.default ?? mod;
|
|
1102
|
+
if (!experience?.Canvas) throw new Error("Experience has no Canvas component");
|
|
1103
|
+
|
|
1104
|
+
Canvas = experience.Canvas;
|
|
1105
|
+
EntryRitual = experience.entryRitual || null;
|
|
1106
|
+
ritualComplete = false;
|
|
1107
|
+
if (!reactRoot) {
|
|
1108
|
+
reactRoot = ReactDOM.createRoot(document.getElementById("root"));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Extract experience title for topbar
|
|
1112
|
+
const expTitle = experience.manifest?.title || experience.name || roomId;
|
|
1113
|
+
|
|
1114
|
+
document.getElementById("loading").style.display = "none";
|
|
1115
|
+
showRoomView();
|
|
1116
|
+
render();
|
|
1117
|
+
updateParticipantList(participants);
|
|
1118
|
+
bundleLoaded = true;
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
console.warn(`[viewer] Bundle load attempt ${attempt + 1} failed:`, err.message);
|
|
1121
|
+
if (attempt >= 2) showError(`Failed to load experience: ${err.message}`);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (msg.type === "shared_state_update") {
|
|
1127
|
+
const serverVersion = msg.stateVersion;
|
|
1128
|
+
|
|
1129
|
+
if (msg.state) {
|
|
1130
|
+
// Full state — replace wholesale (reset, recovery, or legacy server)
|
|
1131
|
+
sharedState = msg.state;
|
|
1132
|
+
} else if (msg.delta) {
|
|
1133
|
+
// Delta mode — merge only changed keys
|
|
1134
|
+
// Gap detection: if we missed updates, request full state
|
|
1135
|
+
if (serverVersion && _stateVersion > 0 && serverVersion > _stateVersion + 1) {
|
|
1136
|
+
// Missed update(s) — apply delta but also request full state recovery
|
|
1137
|
+
console.warn(`[viewer] State version gap: ${_stateVersion} → ${serverVersion}, recovering...`);
|
|
1138
|
+
fetch(`${SERVER}${roomId === "local" ? "" : `/rooms/${roomId}`}/state`)
|
|
1139
|
+
.then(r => r.ok ? r.json() : null)
|
|
1140
|
+
.then(data => {
|
|
1141
|
+
if (data?.sharedState) {
|
|
1142
|
+
sharedState = data.sharedState;
|
|
1143
|
+
optimisticOverlays.clear();
|
|
1144
|
+
render();
|
|
1145
|
+
}
|
|
1146
|
+
})
|
|
1147
|
+
.catch(() => {});
|
|
1148
|
+
}
|
|
1149
|
+
// Apply delta: merge changed keys, remove deleted keys
|
|
1150
|
+
sharedState = { ...sharedState, ...msg.delta };
|
|
1151
|
+
if (msg.deletedKeys) {
|
|
1152
|
+
for (const key of msg.deletedKeys) {
|
|
1153
|
+
delete sharedState[key];
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (serverVersion) _stateVersion = serverVersion;
|
|
1159
|
+
participants = msg.participants || participants;
|
|
1160
|
+
// Server is authoritative — clear all optimistic overlays.
|
|
1161
|
+
optimisticOverlays.clear();
|
|
1162
|
+
render();
|
|
1163
|
+
updateParticipantList(participants);
|
|
1164
|
+
if (msg.event) {
|
|
1165
|
+
addActivityEvent(msg.event);
|
|
1166
|
+
// Log agent tool calls (skip tick engine noise)
|
|
1167
|
+
const ev = msg.event;
|
|
1168
|
+
if (ev.tool && ev.actorId && !ev.actorId.startsWith("_tick")) {
|
|
1169
|
+
const isAI = _participantDetails.find(d => d.actorId === ev.actorId)?.type === "ai";
|
|
1170
|
+
const tag = isAI ? "🤖" : "👤";
|
|
1171
|
+
const inputStr = ev.input ? JSON.stringify(ev.input) : "{}";
|
|
1172
|
+
const outputStr = ev.error ? `❌ ${ev.error}` : (ev.output ? JSON.stringify(ev.output) : "ok");
|
|
1173
|
+
console.log(`%c[ACTION] ${tag} ${ev.actorId} → ${ev.tool}(${inputStr}) → ${outputStr}`, "color: #60a5fa; font-size: 12px");
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (msg.type === "ephemeral") {
|
|
1179
|
+
// Another participant's ephemeral data — merge into ephemeralState
|
|
1180
|
+
const senderId = msg.actorId;
|
|
1181
|
+
if (senderId && senderId !== actorId) {
|
|
1182
|
+
ephemeralState = {
|
|
1183
|
+
...ephemeralState,
|
|
1184
|
+
[senderId]: { ...(ephemeralState[senderId] || {}), ...msg.data },
|
|
1185
|
+
};
|
|
1186
|
+
render();
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (msg.type === "presence_update") {
|
|
1191
|
+
const prevSet = new Set(participants);
|
|
1192
|
+
participants = msg.participants || participants;
|
|
1193
|
+
if (msg.participantDetails) _participantDetails = msg.participantDetails;
|
|
1194
|
+
const newSet = new Set(participants);
|
|
1195
|
+
// Log joins
|
|
1196
|
+
for (const pid of newSet) {
|
|
1197
|
+
if (!prevSet.has(pid)) {
|
|
1198
|
+
const detail = _participantDetails.find(d => d.actorId === pid);
|
|
1199
|
+
const typeTag = detail?.type === "ai" ? "🤖" : detail?.type === "human" ? "👤" : "❓";
|
|
1200
|
+
const roleTag = detail?.role ? ` [${detail.role}]` : "";
|
|
1201
|
+
const ownerTag = detail?.owner ? ` (owner: ${detail.owner})` : "";
|
|
1202
|
+
console.log(`%c[AGENT JOIN] ${typeTag} ${pid}${roleTag}${ownerTag}`, "color: #4ade80; font-weight: bold; font-size: 13px");
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
// Log leaves
|
|
1206
|
+
for (const pid of prevSet) {
|
|
1207
|
+
if (!newSet.has(pid)) {
|
|
1208
|
+
const detail = _participantDetails.find(d => d.actorId === pid);
|
|
1209
|
+
const typeTag = detail?.type === "ai" ? "🤖" : detail?.type === "human" ? "👤" : "❓";
|
|
1210
|
+
const roleTag = detail?.role ? ` [${detail.role}]` : "";
|
|
1211
|
+
console.log(`%c[AGENT LEAVE] ${typeTag} ${pid}${roleTag}`, "color: #f87171; font-weight: bold; font-size: 13px");
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
// Prune ephemeral state for departed participants
|
|
1215
|
+
const activeSet = new Set(participants);
|
|
1216
|
+
for (const key of Object.keys(ephemeralState)) {
|
|
1217
|
+
if (!activeSet.has(key)) delete ephemeralState[key];
|
|
1218
|
+
}
|
|
1219
|
+
render();
|
|
1220
|
+
updateParticipantList(participants);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (msg.type === "kicked") {
|
|
1224
|
+
showToast("You were kicked from the room", "error", 5000);
|
|
1225
|
+
setTimeout(() => navigateToLibrary(), 2000);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (msg.type === "kick_error") {
|
|
1229
|
+
showToast("Kick failed: " + msg.error, "error", 4000);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (msg.type === "kick_success") {
|
|
1233
|
+
showToast("Kicked " + msg.actorId, "info", 2000);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (msg.type === "experience_updated") {
|
|
1237
|
+
// Hot reload: re-fetch bundle and re-render
|
|
1238
|
+
try {
|
|
1239
|
+
const bundleUrl = roomId === "local"
|
|
1240
|
+
? `${SERVER}/bundle?_t=${Date.now()}`
|
|
1241
|
+
: `${SERVER}/rooms/${roomId}/bundle?_t=${Date.now()}`;
|
|
1242
|
+
const bundleRes = await fetch(bundleUrl);
|
|
1243
|
+
if (!bundleRes.ok) throw new Error(`Bundle fetch failed: ${bundleRes.status} ${bundleRes.statusText}`);
|
|
1244
|
+
const bundleCode = await bundleRes.text();
|
|
1245
|
+
|
|
1246
|
+
const blob = new Blob([bundleCode], { type: "text/javascript" });
|
|
1247
|
+
const url = URL.createObjectURL(blob);
|
|
1248
|
+
const mod = await import(url);
|
|
1249
|
+
URL.revokeObjectURL(url);
|
|
1250
|
+
|
|
1251
|
+
const experience = mod.default ?? mod;
|
|
1252
|
+
if (experience?.Canvas) {
|
|
1253
|
+
Canvas = experience.Canvas;
|
|
1254
|
+
EntryRitual = experience.entryRitual || null;
|
|
1255
|
+
ritualComplete = false;
|
|
1256
|
+
hotReloadKey++;
|
|
1257
|
+
// Update title in case it changed
|
|
1258
|
+
const expTitle = experience.manifest?.title || experience.name || roomId;
|
|
1259
|
+
updateTopbarTitle(expTitle);
|
|
1260
|
+
render();
|
|
1261
|
+
console.log("[viewer] Hot reloaded");
|
|
1262
|
+
}
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
console.error("[viewer] Hot reload failed:", err);
|
|
1265
|
+
showToast(`Hot reload failed: ${err.message}`, "build", 10000);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
if (msg.type === "build_error") {
|
|
1270
|
+
showToast(`Build error:\n${msg.error}`, "build", 15000);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (msg.type === "screenshot_request") {
|
|
1274
|
+
const requestId = msg.id;
|
|
1275
|
+
// Small delay to let any pending React renders settle
|
|
1276
|
+
setTimeout(async () => {
|
|
1277
|
+
try {
|
|
1278
|
+
const root = document.getElementById("root");
|
|
1279
|
+
if (!root) throw new Error("No #root element");
|
|
1280
|
+
|
|
1281
|
+
let dataUrl;
|
|
1282
|
+
|
|
1283
|
+
// Fast path: single <canvas> filling #root → native toDataURL
|
|
1284
|
+
const canvases = root.querySelectorAll("canvas");
|
|
1285
|
+
if (canvases.length === 1) {
|
|
1286
|
+
const cvs = canvases[0];
|
|
1287
|
+
const rootRect = root.getBoundingClientRect();
|
|
1288
|
+
const cvsRect = cvs.getBoundingClientRect();
|
|
1289
|
+
const fillsRoot =
|
|
1290
|
+
Math.abs(cvsRect.width - rootRect.width) < 20 &&
|
|
1291
|
+
Math.abs(cvsRect.height - rootRect.height) < 20;
|
|
1292
|
+
if (fillsRoot) {
|
|
1293
|
+
dataUrl = cvs.toDataURL("image/png");
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Snapshot all canvases before html2canvas (it can't read WebGL content)
|
|
1298
|
+
const canvasSnapshots = new Map();
|
|
1299
|
+
if (!dataUrl) {
|
|
1300
|
+
canvases.forEach((cvs) => {
|
|
1301
|
+
try {
|
|
1302
|
+
const snap = cvs.toDataURL("image/png");
|
|
1303
|
+
if (snap && snap.length > 100) {
|
|
1304
|
+
canvasSnapshots.set(cvs, snap);
|
|
1305
|
+
}
|
|
1306
|
+
} catch {}
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Fallback: html2canvas for DOM/SVG/mixed content
|
|
1311
|
+
if (!dataUrl && typeof html2canvas === "function") {
|
|
1312
|
+
const captured = await html2canvas(root, {
|
|
1313
|
+
backgroundColor: "#0a0a0a",
|
|
1314
|
+
useCORS: true,
|
|
1315
|
+
logging: false,
|
|
1316
|
+
scale: 1,
|
|
1317
|
+
onclone: (doc, clonedRoot) => {
|
|
1318
|
+
// Replace cloned WebGL canvases with snapshot images
|
|
1319
|
+
const clonedCanvases = clonedRoot.querySelectorAll("canvas");
|
|
1320
|
+
clonedCanvases.forEach((clonedCvs, idx) => {
|
|
1321
|
+
const originalCvs = canvases[idx];
|
|
1322
|
+
const snapshot = canvasSnapshots.get(originalCvs);
|
|
1323
|
+
if (snapshot) {
|
|
1324
|
+
const img = doc.createElement("img");
|
|
1325
|
+
img.src = snapshot;
|
|
1326
|
+
img.style.width = clonedCvs.style.width || (clonedCvs.width + "px");
|
|
1327
|
+
img.style.height = clonedCvs.style.height || (clonedCvs.height + "px");
|
|
1328
|
+
img.style.display = clonedCvs.style.display;
|
|
1329
|
+
clonedCvs.parentNode?.replaceChild(img, clonedCvs);
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
dataUrl = captured.toDataURL("image/png");
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
if (!dataUrl) throw new Error("No capture method available");
|
|
1338
|
+
|
|
1339
|
+
try { ws.send(JSON.stringify({
|
|
1340
|
+
type: "screenshot_response",
|
|
1341
|
+
id: requestId,
|
|
1342
|
+
dataUrl,
|
|
1343
|
+
})); } catch (_) { /* WS may have closed */ }
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
try { ws.send(JSON.stringify({
|
|
1346
|
+
type: "screenshot_response",
|
|
1347
|
+
id: requestId,
|
|
1348
|
+
error: err.message,
|
|
1349
|
+
})); } catch (_) { /* WS may have closed */ }
|
|
1350
|
+
}
|
|
1351
|
+
}, 100);
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
ws.onerror = (err) => {
|
|
1356
|
+
console.error("[viewer] WebSocket error:", err);
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
// Reconnect with exponential backoff (no full page reload)
|
|
1360
|
+
ws.onclose = () => {
|
|
1361
|
+
// Don't reconnect if user navigated away from this room
|
|
1362
|
+
if (currentWs !== ws) return;
|
|
1363
|
+
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 15000);
|
|
1364
|
+
reconnectAttempts++;
|
|
1365
|
+
console.log(`[viewer] WebSocket closed, reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`);
|
|
1366
|
+
showToast(`Disconnected. Reconnecting in ${Math.round(delay/1000)}s...`, "info", delay + 1000);
|
|
1367
|
+
reconnectTimer = setTimeout(() => { reconnectTimer = null; if (currentWs === ws) start(); }, delay);
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
} catch (err) {
|
|
1371
|
+
showError(err.message);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
let reconnectAttempts = 0;
|
|
1376
|
+
let reconnectTimer = null; // Stored so we can clear on navigation
|
|
1377
|
+
let reactRoot = null; // Persists across reconnects to avoid duplicate roots
|
|
1378
|
+
|
|
1379
|
+
// Initial routing: if path has a room ID (e.g. /room-abc), connect to it.
|
|
1380
|
+
// Otherwise show the library.
|
|
1381
|
+
if (ROOM_ID) {
|
|
1382
|
+
start();
|
|
1383
|
+
} else {
|
|
1384
|
+
showLibraryView();
|
|
1385
|
+
}
|
|
1386
|
+
</script>
|
|
1387
|
+
</body>
|
|
1388
|
+
</html>
|