@marshalliqiu/loupe 0.1.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,607 @@
1
+ /* global EventSource, document, location, window */
2
+
3
+ const sessionDataElement = document.getElementById("lavish-session");
4
+ const sessionData = JSON.parse(sessionDataElement?.textContent || "{}");
5
+ const key = String(sessionData.key || "");
6
+ const filePath = String(sessionData.file || "");
7
+ const queueStorageKey = "lavish-axi:queued:" + key;
8
+ const internalQueueKeyField = "_lavishQueueKey";
9
+ const initialChat = Array.isArray(sessionData.initialChat) ? sessionData.initialChat : [];
10
+
11
+ const frame = /** @type {HTMLIFrameElement} */ (document.getElementById("artifact"));
12
+ const annotationPills = /** @type {HTMLDivElement} */ (document.getElementById("annotationPills"));
13
+ const chatLog = /** @type {HTMLDivElement} */ (document.getElementById("chatLog"));
14
+ const chatInput = /** @type {HTMLTextAreaElement} */ (document.getElementById("chatInput"));
15
+ const sendButton = /** @type {HTMLButtonElement} */ (document.getElementById("send"));
16
+ const sendCaret = /** @type {HTMLButtonElement} */ (document.getElementById("sendCaret"));
17
+ const sendActions = /** @type {HTMLDivElement} */ (document.getElementById("sendActions"));
18
+ const sendMenu = /** @type {HTMLDivElement} */ (document.getElementById("sendMenu"));
19
+ const sendFromMenuButton = /** @type {HTMLButtonElement} */ (document.getElementById("sendFromMenu"));
20
+ const sendAndEndButton = /** @type {HTMLButtonElement} */ (document.getElementById("sendAndEnd"));
21
+ const annotationSwitch = /** @type {HTMLButtonElement} */ (document.getElementById("annotation"));
22
+ const moreWrap = /** @type {HTMLDivElement} */ (document.getElementById("moreWrap"));
23
+ const moreButton = /** @type {HTMLButtonElement} */ (document.getElementById("moreButton"));
24
+ const moreMenu = /** @type {HTMLDivElement} */ (document.getElementById("moreMenu"));
25
+ const reloadArtifactButton = /** @type {HTMLButtonElement} */ (document.getElementById("reloadArtifact"));
26
+ const copySnapshotButton = /** @type {HTMLButtonElement} */ (document.getElementById("copySnapshot"));
27
+ const endButton = /** @type {HTMLButtonElement} */ (document.getElementById("end"));
28
+ const copyPathButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyPath"));
29
+ const copyHint = /** @type {HTMLSpanElement} */ (document.getElementById("copyHint"));
30
+ const copyHintText = /** @type {HTMLSpanElement} */ (document.getElementById("copyHintText"));
31
+ const presenceBanner = /** @type {HTMLDivElement} */ (document.getElementById("presenceBanner"));
32
+ const endedOverlay = /** @type {HTMLDivElement} */ (document.getElementById("endedOverlay"));
33
+ const layoutGateOverlay = /** @type {HTMLDivElement} */ (document.getElementById("layoutGateOverlay"));
34
+ const layoutGateTitle = /** @type {HTMLDivElement} */ (document.getElementById("layoutGateTitle"));
35
+ const layoutGateCopy = /** @type {HTMLParagraphElement} */ (document.getElementById("layoutGateCopy"));
36
+ const layoutGateAction = /** @type {HTMLButtonElement} */ (document.getElementById("layoutGateAction"));
37
+ const layoutIssueBanner = /** @type {HTMLDivElement} */ (document.getElementById("layoutIssueBanner"));
38
+ const sendHint = /** @type {HTMLSpanElement} */ (document.getElementById("sendHint"));
39
+ const artifactSrc = frame.dataset.artifactSrc || frame.getAttribute?.("data-artifact-src") || frame.src || "";
40
+
41
+ const queued = loadQueuedPrompts();
42
+ let annotation = true;
43
+ let ended = false;
44
+ let agentPresence = "waiting";
45
+ let pendingSnapshot = "";
46
+ const layoutGateEnabled = sessionData.layoutGateEnabled !== false;
47
+ const configuredLayoutGateMaxHoldMs = Number(sessionData.layoutGateMaxHoldMs);
48
+ const layoutGateMaxHoldMs =
49
+ Number.isFinite(configuredLayoutGateMaxHoldMs) && configuredLayoutGateMaxHoldMs > 0
50
+ ? Math.min(configuredLayoutGateMaxHoldMs, 60_000)
51
+ : 12_000;
52
+ let layoutGateVisible = false;
53
+ let layoutGateArmed = false;
54
+ let layoutGateManuallyBypassed = !layoutGateEnabled;
55
+ let layoutGateCycle = 0;
56
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
57
+ let layoutGateTimer;
58
+ const snapshotRequests = [];
59
+ let endAfterSubmit = false;
60
+ let workingBubble = null;
61
+ let submitQueuedPromise = null;
62
+ let submitQueuedAgain = false;
63
+ let lastScroll = { x: 0, y: 0 };
64
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
65
+ let copyHintTimer;
66
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
67
+ let sendHintTimer;
68
+
69
+ function escapeHtml(value) {
70
+ return String(value).replace(
71
+ /[&<>"']/g,
72
+ (char) =>
73
+ ({
74
+ "&": "&amp;",
75
+ "<": "&lt;",
76
+ ">": "&gt;",
77
+ '"': "&quot;",
78
+ "'": "&#39;",
79
+ })[char],
80
+ );
81
+ }
82
+
83
+ function loadQueuedPrompts() {
84
+ try {
85
+ const parsed = JSON.parse(sessionStorage.getItem(queueStorageKey) || "[]");
86
+ return Array.isArray(parsed) ? parsed.filter((prompt) => prompt && typeof prompt === "object") : [];
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ function persistQueuedPrompts() {
93
+ try {
94
+ if (queued.length) {
95
+ sessionStorage.setItem(queueStorageKey, JSON.stringify(queued));
96
+ } else {
97
+ sessionStorage.removeItem(queueStorageKey);
98
+ }
99
+ } catch {
100
+ // The in-memory queue still works if browser storage is unavailable.
101
+ }
102
+ }
103
+
104
+ function render() {
105
+ annotationPills.innerHTML = queued
106
+ .map(
107
+ (prompt, index) =>
108
+ '<div class="pill-wrap"><div class="pill"><span class="pill-preview">' +
109
+ escapeHtml(prompt.prompt) +
110
+ '</span><button class="pill-close" type="button" aria-label="Remove queued prompt" data-index="' +
111
+ index +
112
+ '">×</button></div><div class="pill-tooltip">' +
113
+ (prompt.selector
114
+ ? '<div class="tooltip-label">Target</div><div class="pill-tooltip-target">' +
115
+ escapeHtml(prompt.selector) +
116
+ "</div>"
117
+ : "") +
118
+ '<div class="tooltip-label">Prompt</div><div class="pill-tooltip-prompt">' +
119
+ escapeHtml(prompt.prompt) +
120
+ "</div></div></div>",
121
+ )
122
+ .join("");
123
+
124
+ for (const button of annotationPills.querySelectorAll(".pill-close")) {
125
+ const closeButton = /** @type {HTMLButtonElement} */ (button);
126
+ closeButton.addEventListener("click", (event) => removeQueuedPrompt(Number(closeButton.dataset.index), event));
127
+ }
128
+ updateSendState();
129
+ }
130
+
131
+ function updateSendState() {
132
+ sendButton.disabled = ended || agentPresence === "working";
133
+ sendCaret.disabled = ended || agentPresence === "working";
134
+ sendFromMenuButton.disabled = sendButton.disabled;
135
+ }
136
+
137
+ function showSendHint() {
138
+ sendHint.hidden = false;
139
+ clearTimeout(sendHintTimer);
140
+ sendHintTimer = setTimeout(() => {
141
+ sendHint.hidden = true;
142
+ }, 2600);
143
+ chatInput.focus();
144
+ }
145
+
146
+ function hideSendHint() {
147
+ clearTimeout(sendHintTimer);
148
+ sendHint.hidden = true;
149
+ }
150
+
151
+ function setMenuOpen(button, menu, open) {
152
+ menu.hidden = !open;
153
+ button.setAttribute("aria-expanded", String(open));
154
+ }
155
+
156
+ function closeMenus() {
157
+ setMenuOpen(moreButton, moreMenu, false);
158
+ setMenuOpen(sendCaret, sendMenu, false);
159
+ }
160
+
161
+ function toggleMenu(button, menu) {
162
+ const open = menu.hidden;
163
+ closeMenus();
164
+ setMenuOpen(button, menu, open);
165
+ }
166
+
167
+ async function copyText(text) {
168
+ try {
169
+ if (navigator.clipboard && navigator.clipboard.writeText) {
170
+ await navigator.clipboard.writeText(text);
171
+ return true;
172
+ }
173
+ } catch {
174
+ // Fall through to the textarea-based fallback below.
175
+ }
176
+ const helper = document.createElement("textarea");
177
+ helper.value = text;
178
+ helper.style.position = "fixed";
179
+ helper.style.opacity = "0";
180
+ document.body.appendChild(helper);
181
+ helper.select();
182
+ document.execCommand("copy");
183
+ helper.remove();
184
+ return true;
185
+ }
186
+
187
+ function addChat(role, text) {
188
+ if (!text) return;
189
+
190
+ const el = document.createElement("div");
191
+ el.className = "bubble " + role;
192
+ el.innerHTML = "<small>" + (role === "agent" ? "Agent" : "You") + "</small><div>" + escapeHtml(text) + "</div>";
193
+ chatLog.appendChild(el);
194
+ chatLog.scrollTop = chatLog.scrollHeight;
195
+ }
196
+
197
+ function syncChat(chat) {
198
+ for (const el of [...chatLog.querySelectorAll(".bubble.user,.bubble.agent:not(.agent-working)")]) {
199
+ el.remove();
200
+ }
201
+
202
+ for (const item of chat) addChat(item.role, item.text);
203
+ if (workingBubble) chatLog.appendChild(workingBubble);
204
+ chatLog.scrollTop = chatLog.scrollHeight;
205
+ }
206
+
207
+ function setAgentPresence(state) {
208
+ agentPresence = state === "listening" || state === "working" ? state : "waiting";
209
+ updateSendState();
210
+ if (presenceBanner) presenceBanner.hidden = ended || agentPresence !== "waiting";
211
+
212
+ if (agentPresence !== "working") {
213
+ if (workingBubble) workingBubble.remove();
214
+ workingBubble = null;
215
+ return;
216
+ }
217
+
218
+ if (!workingBubble) {
219
+ workingBubble = document.createElement("div");
220
+ workingBubble.className = "bubble agent agent-working";
221
+ workingBubble.innerHTML = '<span class="spinner"></span><span>Working...</span>';
222
+ chatLog.appendChild(workingBubble);
223
+ }
224
+ chatLog.scrollTop = chatLog.scrollHeight;
225
+ }
226
+
227
+ function removeQueuedPrompt(index, event) {
228
+ if (event) event.stopPropagation();
229
+ queued.splice(index, 1);
230
+ persistQueuedPrompts();
231
+ render();
232
+ }
233
+
234
+ function promptQueueKey(prompt) {
235
+ return prompt && typeof prompt[internalQueueKeyField] === "string" ? prompt[internalQueueKeyField].trim() : "";
236
+ }
237
+
238
+ function enqueuePrompt(prompt) {
239
+ if (!prompt || typeof prompt !== "object") return;
240
+
241
+ const queueKey = promptQueueKey(prompt);
242
+ if (queueKey) {
243
+ const index = queued.findIndex((item) => promptQueueKey(item) === queueKey);
244
+ if (index !== -1) {
245
+ queued[index] = prompt;
246
+ } else {
247
+ queued.push(prompt);
248
+ }
249
+ } else {
250
+ queued.push(prompt);
251
+ }
252
+
253
+ persistQueuedPrompts();
254
+ render();
255
+ }
256
+
257
+ function stripInternalPromptFields(prompt) {
258
+ if (!prompt || typeof prompt !== "object") return prompt;
259
+ const clean = { ...prompt };
260
+ delete clean[internalQueueKeyField];
261
+ return clean;
262
+ }
263
+
264
+ function postToFrame(message) {
265
+ if (frame.contentWindow) frame.contentWindow.postMessage(message, "*");
266
+ }
267
+
268
+ function requestSnapshot(action) {
269
+ snapshotRequests.push(action);
270
+ postToFrame({ type: "lavish:requestSnapshot" });
271
+ }
272
+
273
+ function sendQueued(endAfter) {
274
+ if (ended || agentPresence === "working") return;
275
+ closeMenus();
276
+
277
+ const text = chatInput.value.trim();
278
+ if (text) {
279
+ queued.push({ uid: "", prompt: text, selector: "", tag: "message", text: "Freeform message" });
280
+ persistQueuedPrompts();
281
+ addChat("user", text);
282
+ chatInput.value = "";
283
+ render();
284
+ }
285
+ if (!queued.length) {
286
+ if (endAfter) {
287
+ endSession();
288
+ } else {
289
+ showSendHint();
290
+ }
291
+ return;
292
+ }
293
+ hideSendHint();
294
+
295
+ if (endAfter) endAfterSubmit = true;
296
+ requestSnapshot("submit");
297
+ }
298
+
299
+ async function submitQueued() {
300
+ if (submitQueuedPromise) {
301
+ submitQueuedAgain = true;
302
+ return submitQueuedPromise;
303
+ }
304
+
305
+ let succeeded = false;
306
+ submitQueuedPromise = submitQueuedOnce();
307
+ try {
308
+ const result = await submitQueuedPromise;
309
+ succeeded = true;
310
+ return result;
311
+ } finally {
312
+ submitQueuedPromise = null;
313
+ const shouldSubmitAgain = submitQueuedAgain;
314
+ submitQueuedAgain = false;
315
+ if (!succeeded) {
316
+ endAfterSubmit = false;
317
+ } else if (shouldSubmitAgain && queued.length) {
318
+ submitQueued();
319
+ } else if (endAfterSubmit) {
320
+ endAfterSubmit = false;
321
+ await endSession();
322
+ }
323
+ }
324
+ }
325
+
326
+ async function submitQueuedOnce() {
327
+ const prompts = queued.slice();
328
+ const response = await fetch("/api/" + key + "/prompts", {
329
+ method: "POST",
330
+ headers: { "content-type": "application/json" },
331
+ body: JSON.stringify({ prompts: prompts.map(stripInternalPromptFields), domSnapshot: pendingSnapshot }),
332
+ });
333
+ if (!response.ok) throw new Error("failed to submit queued prompts");
334
+ for (const prompt of prompts) {
335
+ const index = queued.indexOf(prompt);
336
+ if (index !== -1) queued.splice(index, 1);
337
+ }
338
+ persistQueuedPrompts();
339
+ render();
340
+ if (agentPresence === "listening") setAgentPresence("working");
341
+ }
342
+
343
+ function normalizeLayoutWarningsPayload(value) {
344
+ return Array.isArray(value) ? value.filter((item) => item && typeof item === "object") : [];
345
+ }
346
+
347
+ function isErrorLayoutWarning(warning) {
348
+ return String(warning?.severity || "").toLowerCase() === "error";
349
+ }
350
+
351
+ function setLayoutIssueBanner(visible, text = "This surface may have layout issues. Your agent has been notified.") {
352
+ if (!layoutIssueBanner) return;
353
+ layoutIssueBanner.textContent = text;
354
+ layoutIssueBanner.hidden = !visible;
355
+ }
356
+
357
+ function clearLayoutGateTimer() {
358
+ if (layoutGateTimer) clearTimeout(layoutGateTimer);
359
+ layoutGateTimer = undefined;
360
+ }
361
+
362
+ function setLayoutGateCard(state) {
363
+ if (!layoutGateTitle || !layoutGateCopy) return;
364
+
365
+ if (state === "held") {
366
+ layoutGateTitle.innerHTML = "Fixing a layout issue...";
367
+ layoutGateCopy.textContent =
368
+ "The real browser found overflow or overlapping content. Your agent has been notified and this will reveal after the next clean reload.";
369
+ return;
370
+ }
371
+
372
+ layoutGateTitle.innerHTML = "Checking layout.<br>One moment.";
373
+ layoutGateCopy.textContent = "Lavish is waiting for fonts and final geometry before revealing this artifact.";
374
+ }
375
+
376
+ function setLayoutGateActive(active) {
377
+ layoutGateVisible = active;
378
+ if (layoutGateOverlay) layoutGateOverlay.hidden = !active;
379
+ document.body?.classList?.toggle("layout-gate-active", active);
380
+ }
381
+
382
+ function revealLayoutGate({ showBanner = false, bannerText = undefined } = {}) {
383
+ clearLayoutGateTimer();
384
+ layoutGateArmed = false;
385
+ setLayoutGateActive(false);
386
+ setLayoutIssueBanner(showBanner, bannerText);
387
+ }
388
+
389
+ function forceRevealLayoutGate(reason) {
390
+ if (!layoutGateEnabled || ended) return;
391
+ if (reason === "manual") layoutGateManuallyBypassed = true;
392
+ const bannerText =
393
+ reason === "timeout"
394
+ ? "This surface may have layout issues. Lavish revealed it after the safety timeout so review is never blocked."
395
+ : "This surface may have layout issues. You chose to show it before the layout check passed.";
396
+ revealLayoutGate({ showBanner: true, bannerText });
397
+ }
398
+
399
+ function startLayoutGateCycle() {
400
+ if (!layoutGateEnabled || layoutGateManuallyBypassed || ended) return;
401
+
402
+ layoutGateCycle += 1;
403
+ layoutGateArmed = true;
404
+ setLayoutIssueBanner(false);
405
+ setLayoutGateCard("checking");
406
+ setLayoutGateActive(true);
407
+ clearLayoutGateTimer();
408
+
409
+ const cycle = layoutGateCycle;
410
+ layoutGateTimer = setTimeout(() => {
411
+ if (cycle !== layoutGateCycle || !layoutGateVisible || ended) return;
412
+ forceRevealLayoutGate("timeout");
413
+ }, layoutGateMaxHoldMs);
414
+ layoutGateTimer?.unref?.();
415
+ }
416
+
417
+ function handleLayoutWarningsForGate(layoutWarnings) {
418
+ const warnings = normalizeLayoutWarningsPayload(layoutWarnings);
419
+ const hasErrors = warnings.some(isErrorLayoutWarning);
420
+
421
+ if (!layoutGateEnabled) return;
422
+
423
+ if (layoutGateManuallyBypassed) {
424
+ setLayoutIssueBanner(hasErrors);
425
+ return;
426
+ }
427
+
428
+ if (!layoutGateArmed && !layoutGateVisible) return;
429
+
430
+ if (!hasErrors) {
431
+ revealLayoutGate();
432
+ return;
433
+ }
434
+
435
+ setLayoutGateCard("held");
436
+ setLayoutGateActive(true);
437
+ }
438
+
439
+ function initializeLayoutGate() {
440
+ if (!layoutGateEnabled) {
441
+ setLayoutGateActive(false);
442
+ setLayoutIssueBanner(false);
443
+ return;
444
+ }
445
+
446
+ if (layoutGateAction) layoutGateAction.onclick = () => forceRevealLayoutGate("manual");
447
+ startLayoutGateCycle();
448
+ }
449
+
450
+ async function submitLayoutWarnings(layoutWarnings) {
451
+ const response = await fetch("/api/" + key + "/layout-warnings", {
452
+ method: "POST",
453
+ headers: { "content-type": "application/json" },
454
+ body: JSON.stringify({ layout_warnings: normalizeLayoutWarningsPayload(layoutWarnings) }),
455
+ });
456
+ if (!response.ok) throw new Error("failed to submit layout warnings");
457
+ }
458
+
459
+ async function endSession() {
460
+ if (ended) return;
461
+ const response = await fetch("/api/" + key + "/end", { method: "POST" });
462
+ if (!response.ok) throw new Error("failed to end session");
463
+ ended = true;
464
+ closeMenus();
465
+ annotationSwitch.disabled = true;
466
+ moreButton.disabled = true;
467
+ chatInput.disabled = true;
468
+ updateSendState();
469
+ if (presenceBanner) presenceBanner.hidden = true;
470
+ layoutGateManuallyBypassed = true;
471
+ revealLayoutGate();
472
+ postToFrame({ type: "lavish:setAnnotationMode", enabled: false });
473
+ endedOverlay.hidden = false;
474
+ }
475
+
476
+ function copyFilePath() {
477
+ copyText(filePath);
478
+ copyHint.classList.add("copied");
479
+ copyHintText.textContent = "Copied";
480
+ clearTimeout(copyHintTimer);
481
+ copyHintTimer = setTimeout(() => {
482
+ copyHint.classList.remove("copied");
483
+ copyHintText.textContent = "Copy";
484
+ }, 1600);
485
+ }
486
+
487
+ function copyDomSnapshot() {
488
+ closeMenus();
489
+ requestSnapshot("copy");
490
+ }
491
+
492
+ function resetFrame() {
493
+ startLayoutGateCycle();
494
+ // The iframe is sandboxed, so reload by resetting the iframe URL from chrome.
495
+ frame.src = artifactSrc || frame.src;
496
+ }
497
+
498
+ function loadFrame() {
499
+ if (artifactSrc) frame.src = artifactSrc;
500
+ }
501
+
502
+ function reloadArtifact() {
503
+ closeMenus();
504
+ resetFrame();
505
+ }
506
+
507
+ async function reloadAfterServerRestart() {
508
+ let sawOutage = false;
509
+ const deadline = Date.now() + 5000;
510
+
511
+ while (Date.now() < deadline) {
512
+ try {
513
+ const res = await fetch("/health", { cache: "no-store" });
514
+ if (sawOutage && res.ok) {
515
+ location.reload();
516
+ return;
517
+ }
518
+ } catch {
519
+ sawOutage = true;
520
+ }
521
+
522
+ await new Promise((resolve) => setTimeout(resolve, 100));
523
+ }
524
+
525
+ location.reload();
526
+ }
527
+
528
+ window.addEventListener("message", (event) => {
529
+ if (event.source !== frame.contentWindow) return;
530
+
531
+ const msg = event.data || {};
532
+ if (msg.type === "lavish:queuePrompt") {
533
+ enqueuePrompt(msg.prompt);
534
+ }
535
+ if (msg.type === "lavish:snapshot") {
536
+ const snapshotAction = snapshotRequests.shift() || "submit";
537
+ if (snapshotAction === "copy") {
538
+ copyText(msg.snapshot || "");
539
+ } else {
540
+ pendingSnapshot = msg.snapshot || "";
541
+ submitQueued();
542
+ }
543
+ }
544
+ if (msg.type === "lavish:scroll") {
545
+ lastScroll = { x: Number(msg.x) || 0, y: Number(msg.y) || 0 };
546
+ }
547
+ if (msg.type === "lavish:layoutWarnings") {
548
+ handleLayoutWarningsForGate(msg.layout_warnings);
549
+ submitLayoutWarnings(msg.layout_warnings).catch(() => {});
550
+ }
551
+ if (msg.type === "lavish:sendQueuedPrompts") sendQueued();
552
+ if (msg.type === "lavish:endSession") endSession();
553
+ });
554
+
555
+ loadFrame();
556
+
557
+ annotationSwitch.onclick = () => {
558
+ annotation = !annotation;
559
+ annotationSwitch.setAttribute("aria-pressed", String(annotation));
560
+ postToFrame({ type: "lavish:setAnnotationMode", enabled: annotation });
561
+ };
562
+
563
+ sendButton.onclick = () => sendQueued(false);
564
+ sendFromMenuButton.onclick = () => sendQueued(false);
565
+ sendAndEndButton.onclick = () => sendQueued(true);
566
+ sendCaret.onclick = () => toggleMenu(sendCaret, sendMenu);
567
+ moreButton.onclick = () => toggleMenu(moreButton, moreMenu);
568
+ chatInput.addEventListener("keydown", (event) => {
569
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
570
+ event.preventDefault();
571
+ sendQueued(false);
572
+ }
573
+ });
574
+ chatInput.addEventListener("input", hideSendHint);
575
+ copyPathButton.onclick = copyFilePath;
576
+ reloadArtifactButton.onclick = reloadArtifact;
577
+ copySnapshotButton.onclick = copyDomSnapshot;
578
+ endButton.onclick = () => {
579
+ closeMenus();
580
+ endSession();
581
+ };
582
+ document.addEventListener("mousedown", (event) => {
583
+ const target = /** @type {Node} */ (event.target);
584
+ if (!moreMenu.hidden && !moreWrap.contains(target)) setMenuOpen(moreButton, moreMenu, false);
585
+ if (!sendMenu.hidden && !sendActions.contains(target)) setMenuOpen(sendCaret, sendMenu, false);
586
+ });
587
+ document.addEventListener("keydown", (event) => {
588
+ if (event.key === "Escape") closeMenus();
589
+ });
590
+ frame.addEventListener("load", () => {
591
+ postToFrame({ type: "lavish:setAnnotationMode", enabled: annotation && !ended });
592
+ // Replay the pre-reload scroll position so hot reloads don't jump the artifact to the top.
593
+ postToFrame({ type: "lavish:restoreScroll", x: lastScroll.x, y: lastScroll.y });
594
+ });
595
+
596
+ initializeLayoutGate();
597
+
598
+ const events = new EventSource("/events/" + key);
599
+ events.addEventListener("reload", () => resetFrame());
600
+ events.addEventListener("chrome-reload", () => reloadAfterServerRestart());
601
+ events.addEventListener("agent-reply", (event) => addChat("agent", JSON.parse(event.data).text));
602
+ events.addEventListener("chat-sync", (event) => syncChat(JSON.parse(event.data).chat || []));
603
+ events.addEventListener("agent-presence", (event) => setAgentPresence(JSON.parse(event.data).state));
604
+
605
+ render();
606
+ initialChat.forEach((item) => addChat(item.role, item.text));
607
+ setAgentPresence("waiting");