@parley-im/parley 0.0.0-stage → 0.1.1

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/web/chat.js ADDED
@@ -0,0 +1,901 @@
1
+ (function () {
2
+ const { base, owner, maxText, relay, sender, project: knownProject } = window.PARLEY;
3
+ const $ = (id) => document.getElementById(id);
4
+ const els = {
5
+ project: $("project"), crumbs: $("crumbs"), status: $("status"), statusDot: $("status-dot"), statusText: $("status-text"),
6
+ banner: $("banner"), layout: $("layout"), scroll: $("scroll"), content: $("content"), intro: $("intro"),
7
+ older: $("older"), olderBtn: $("older-btn"), messages: $("messages"), dock: $("dock"), composer: $("composer"),
8
+ input: $("input"), send: $("send"), file: $("file"), attachments: $("attachments"), paused: $("paused"),
9
+ hints: $("hints"), hintLeft: $("hint-left"), hintRight: $("hint-right"), drop: $("drop"), side: $("side"),
10
+ chip: $("chip"), infoBtn: $("info-btn"), sheet: $("sheet"), sheetBack: $("sheet-back"), sheetBody: $("sheet-body"),
11
+ sheetClose: $("sheet-close"), app: document.querySelector(".app"),
12
+ };
13
+ if (!owner) document.body.classList.add("tester-view");
14
+
15
+ let session = null;
16
+ let ownerName = null;
17
+ let repliesLeft = null;
18
+ let more = false;
19
+ let loadingOlder = false;
20
+ const seen = new Set();
21
+ let live = null; // { el, text, steps, bodyEl, stepsEl }
22
+ let attachments = []; // { handle, preview, uploading }
23
+ let sending = false;
24
+ let ended = false;
25
+ let offline = false;
26
+
27
+ const agentName = () => (session?.agent === "codex" ? "Codex" : "Claude");
28
+ const ownerLabel = () => ownerName || "the developer";
29
+ const clock = (iso) => new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
30
+ const touch = matchMedia("(pointer: coarse)").matches;
31
+ const el = (tag, props = {}, ...children) => {
32
+ const node = Object.assign(document.createElement(tag), props);
33
+ node.append(...children.filter((c) => c != null));
34
+ return node;
35
+ };
36
+
37
+ // ---------- staying at the bottom ----------
38
+ //
39
+ // If you're following along at the bottom, you stay there whatever changes:
40
+ // your own message, streamed text, status lines, images, a resize, the
41
+ // keyboard. Only you can unpin it, by scrolling up yourself (wheel, touch,
42
+ // keys, scrollbar). Scroll events the page causes don't count, which is
43
+ // what used to strand you above your own message.
44
+
45
+ const scroller = els.scroll;
46
+ const PIN = 48;
47
+ let following = true;
48
+ let fromBottom = 0;
49
+ let viewHeight = scroller.clientHeight;
50
+ let yoursUntil = 0;
51
+ let touching = false;
52
+ let dragging = false;
53
+
54
+ const distance = () => Math.max(0, scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight);
55
+ const yours = () => touching || dragging || performance.now() < yoursUntil;
56
+ const markYours = (ms) => (yoursUntil = Math.max(yoursUntil, performance.now() + ms));
57
+
58
+ scroller.addEventListener("wheel", () => markYours(250), { passive: true });
59
+ scroller.addEventListener("touchstart", () => (touching = true), { passive: true });
60
+ // Momentum keeps scrolling after your finger lifts.
61
+ scroller.addEventListener("touchend", () => ((touching = false), markYours(1500)), { passive: true });
62
+ scroller.addEventListener("pointerdown", (e) => {
63
+ if (e.target === scroller) dragging = true; // the scrollbar
64
+ });
65
+ addEventListener("pointerup", () => {
66
+ if (dragging) (dragging = false), markYours(300);
67
+ });
68
+ addEventListener("keydown", (e) => {
69
+ if (e.target === els.input) return;
70
+ if (["PageUp", "PageDown", "ArrowUp", "ArrowDown", "Home", "End", " "].includes(e.key)) markYours(500);
71
+ });
72
+
73
+ function toEnd() {
74
+ following = true;
75
+ fromBottom = 0;
76
+ scroller.scrollTop = scroller.scrollHeight;
77
+ }
78
+
79
+ function viewResized() {
80
+ viewHeight = scroller.clientHeight;
81
+ if (following) toEnd();
82
+ else scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight - fromBottom;
83
+ }
84
+
85
+ scroller.addEventListener(
86
+ "scroll",
87
+ () => {
88
+ if (scroller.clientHeight !== viewHeight) return viewResized();
89
+ const d = distance();
90
+ if (d < PIN) {
91
+ following = true;
92
+ fromBottom = 0;
93
+ } else if (yours()) {
94
+ following = false;
95
+ fromBottom = d;
96
+ }
97
+ },
98
+ { passive: true },
99
+ );
100
+
101
+ // The conversation changed size.
102
+ new ResizeObserver(() => {
103
+ if (following) toEnd();
104
+ else fromBottom = distance();
105
+ }).observe(els.content);
106
+
107
+ // The view changed size: window, keyboard, composer growing.
108
+ new ResizeObserver(() => {
109
+ if (scroller.clientHeight !== viewHeight) viewResized();
110
+ }).observe(scroller);
111
+
112
+ // iOS doesn't shrink the page for the keyboard; it shrinks and scrolls the
113
+ // visible area instead. While a keyboard is up, pin the app to exactly that
114
+ // area (its size and its offset), or Safari shows the composer floating at
115
+ // the top with empty space under it.
116
+ const vv = window.visualViewport;
117
+ if (vv) {
118
+ const fit = () => {
119
+ if (vv.height < window.innerHeight - 80) {
120
+ Object.assign(els.app.style, {
121
+ position: "fixed",
122
+ left: "0",
123
+ right: "0",
124
+ top: "0",
125
+ height: `${vv.height}px`,
126
+ transform: `translateY(${vv.offsetTop}px)`,
127
+ });
128
+ } else els.app.removeAttribute("style");
129
+ };
130
+ vv.addEventListener("resize", fit);
131
+ vv.addEventListener("scroll", fit);
132
+ addEventListener("resize", fit);
133
+ }
134
+
135
+ // ---------- messages ----------
136
+
137
+ function messageEl(m) {
138
+ const time = clock(m.createdAt);
139
+ const body = m.role === "tester" && m.body === "(screenshot)" && m.images.length ? null : el("div", { className: "body" });
140
+ if (body) body.innerHTML = window.renderMarkdown(m.body);
141
+ const shots = m.images.length
142
+ ? el(
143
+ "div",
144
+ { className: "shots" },
145
+ ...m.images.map((ref) => {
146
+ const img = el("img", { alt: "Screenshot" });
147
+ transport.image(ref).then(
148
+ (src) => {
149
+ img.src = src;
150
+ img.addEventListener("click", () => lightbox(src));
151
+ },
152
+ () => (img.alt = "Screenshot (can't load while the sender's computer is offline)"),
153
+ );
154
+ return img;
155
+ }),
156
+ )
157
+ : null;
158
+
159
+ if (m.role === "tester") {
160
+ const who = owner ? session.tester : "You";
161
+ return el("li", { className: "msg tester" }, el("div", { className: "bubble" }, body, shots), el("div", { className: "stamp", textContent: `${who} · ${time}` }));
162
+ }
163
+ if (m.role === "error") return el("li", { className: "msg error" }, body);
164
+ const meta = el("div", { className: "meta" }, el("span", { className: "who", textContent: agentName() }), ` · ${time}`);
165
+ return el("li", { className: "msg agent" }, meta, body, shots, owner && m.turn ? trailEl(m.turn) : null);
166
+ }
167
+
168
+ // Your view: what the reply looked at, which model wrote it, and how.
169
+ function trailEl(turn) {
170
+ const bits = [`${turn.activity.length} lookup${turn.activity.length === 1 ? "" : "s"}`];
171
+ if (turn.seconds != null) bits.push(`${turn.seconds}s`);
172
+ if (turn.model) bits.push(turn.model);
173
+ if (turn.auth) bits.push(turn.auth === "none" ? "subscription" : turn.auth);
174
+ if (turn.costUsd != null) bits.push(`$${Number(turn.costUsd).toFixed(3)} at API prices`);
175
+ return el(
176
+ "details",
177
+ { className: "trail" },
178
+ el("summary", { textContent: bits.join(" · ") }),
179
+ el("ul", {}, ...turn.activity.map((line) => el("li", { textContent: line }))),
180
+ );
181
+ }
182
+
183
+ function addMessage(m, { prepend = false } = {}) {
184
+ if (seen.has(m.id)) return;
185
+ seen.add(m.id);
186
+ const node = messageEl(m);
187
+ if (prepend) els.messages.prepend(node);
188
+ else if (live?.el.isConnected) els.messages.insertBefore(node, live.el);
189
+ else els.messages.append(node);
190
+ if (m.role !== "tester" && !prepend) clearLive();
191
+ }
192
+
193
+ function showLive(state) {
194
+ clearLive();
195
+ const meta = el("div", { className: "meta" }, el("span", { className: "who", textContent: agentName() }), " · working");
196
+ const bodyEl = el("div", { className: "body" });
197
+ const stepsEl = el("ul", { className: "steps" });
198
+ const node = el("li", { className: "msg agent live" }, meta, bodyEl, stepsEl);
199
+ live = { el: node, text: state.text || "", steps: state.activity ? [state.activity] : [], bodyEl, stepsEl };
200
+ els.messages.append(node);
201
+ paintLive();
202
+ paintStatus();
203
+ }
204
+
205
+ function paintLive() {
206
+ if (!live) return;
207
+ if (live.text.trim()) {
208
+ live.bodyEl.innerHTML = window.renderMarkdown(live.text);
209
+ live.bodyEl.lastElementChild?.classList.add("cursor");
210
+ } else live.bodyEl.innerHTML = `<span class="thinking"><span></span><span></span><span></span></span>`;
211
+ // The last few things it looked at, newest highlighted.
212
+ live.stepsEl.textContent = "";
213
+ live.steps.slice(-4).forEach((line, i, shown) => live.stepsEl.append(el("li", { className: i === shown.length - 1 ? "now" : "", textContent: line })));
214
+ }
215
+
216
+ function clearLive() {
217
+ live?.el.remove();
218
+ live = null;
219
+ paintStatus();
220
+ }
221
+
222
+ async function loadOlder() {
223
+ if (!more || loadingOlder) return;
224
+ const oldest = Math.min(...seen);
225
+ if (!Number.isFinite(oldest)) return;
226
+ loadingOlder = true;
227
+ els.olderBtn.textContent = "Loading…";
228
+ try {
229
+ const data = await transport.history(oldest);
230
+ // Keep what you're looking at in place while older messages go above it.
231
+ const before = scroller.scrollHeight;
232
+ for (const m of data.messages.slice().reverse()) addMessage(m, { prepend: true });
233
+ scroller.scrollTop += scroller.scrollHeight - before;
234
+ more = data.more;
235
+ } catch {
236
+ banner("Couldn't load earlier messages. Try again in a moment.");
237
+ } finally {
238
+ loadingOlder = false;
239
+ els.older.hidden = !more;
240
+ els.olderBtn.textContent = "Show earlier messages";
241
+ }
242
+ }
243
+ els.olderBtn.addEventListener("click", loadOlder);
244
+ new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && loadOlder(), { root: scroller, rootMargin: "200px 0px 0px 0px" }).observe(els.older);
245
+
246
+ // ---------- chrome around the conversation ----------
247
+
248
+ // Who's answering, with the model once a reply says which one.
249
+ const speaker = () => (session?.model ? `${agentName()} ${session.model}`.replace(/^Claude (?=Opus|Sonnet|Haiku|Fable)/, "") : agentName());
250
+
251
+ function paintStatus() {
252
+ if (!session) return;
253
+ els.status.hidden = false;
254
+ let dot = "on";
255
+ let label = owner ? `${agentName()} · live` : speaker();
256
+ if (offline) (dot = "warn"), (label = "reconnecting");
257
+ else if (live) (dot = "busy"), (label = owner ? `${agentName()} is replying` : "replying…");
258
+ else if (!owner && repliesLeft === 0) (dot = "warn"), (label = "paused");
259
+ else if (owner && session.status !== "open") (dot = "off"), (label = "ended");
260
+ els.statusDot.className = `dot ${dot}`;
261
+ els.statusText.textContent = label;
262
+ if (!owner && repliesLeft != null) {
263
+ els.chip.hidden = false;
264
+ els.chip.textContent = `${repliesLeft} left`;
265
+ els.chip.title = `${repliesLeft} of ${session.replyLimit} replies left in this chat`;
266
+ els.chip.className = `chip ${repliesLeft <= 10 ? "low" : ""}`;
267
+ }
268
+ }
269
+
270
+ // Everything a tester might wonder about, behind the info button.
271
+ function openSheet() {
272
+ const who = sender?.name || ownerName || "The person who sent this link";
273
+ const whom = ownerName || "them";
274
+ const theirs = ownerName ? `${ownerName}'s` : "their";
275
+ const projectName = session?.project ?? knownProject ?? "this project";
276
+ const strong = (t) => el("strong", { textContent: t });
277
+ const kv = (pairs) => el("dl", { className: "kv" }, ...pairs.flatMap(([k, v]) => [el("dt", { textContent: k }), el("dd", { textContent: v })]));
278
+ const facts = [];
279
+ if (sender) facts.push(["Sent by", `${sender.name} (${sender.email})`]);
280
+ if (session) facts.push(["Talking with", session.model ? `${agentName()} (${session.model})` : agentName()]);
281
+ if (session && repliesLeft != null) facts.push(["Replies left", `${repliesLeft} of ${session.replyLimit}`]);
282
+ els.sheetBody.replaceChildren(
283
+ el("p", {}, strong(who), " set this up to hear what you think of ", strong(projectName), sender ? `. parley.im checked it's really them, with Google.` : "."),
284
+ kv(facts),
285
+ el("p", {}, `${agentName()} runs on ${theirs} computer. It can read ${projectName}'s code to answer your questions, but it can't change anything. Everything you write here, screenshots included, is saved for ${whom}.`),
286
+ relay
287
+ ? el(
288
+ "p",
289
+ {},
290
+ `Messages and screenshots are end-to-end encrypted between this page and ${theirs} computer. parley.im passes them along but can't read them; it knows who sent this link, the project's name and when messages go by. The key is the part of your link after the #, so anyone with the whole link can read this chat: keep it to yourself. (This page's code comes from parley.im, so that promise relies on parley.im serving it honestly. And ${agentName()} reads the conversation to reply, as with any ${agentName()} chat.)`,
291
+ )
292
+ : null,
293
+ el("p", {}, `When the replies run out, the chat pauses until ${whom} adds more. Replies come from ${theirs} computer, so it needs to be on, and the link stops working for good if ${whom} ends the chat.`),
294
+ );
295
+ els.sheet.hidden = els.sheetBack.hidden = false;
296
+ els.sheetClose.focus();
297
+ }
298
+ const closeSheet = () => (els.sheet.hidden = els.sheetBack.hidden = true);
299
+ els.infoBtn.addEventListener("click", openSheet);
300
+ els.sheetClose.addEventListener("click", closeSheet);
301
+ els.sheetBack.addEventListener("click", closeSheet);
302
+ addEventListener("keydown", (e) => e.key === "Escape" && closeSheet());
303
+
304
+ // The tester's intro. On parley.im links it can show before the
305
+ // conversation loads, from what parley.im vouches for.
306
+ function paintTester() {
307
+ const name = ownerName || sender?.name?.split(" ")[0];
308
+ const projectName = session?.project ?? knownProject ?? "";
309
+ els.project.textContent = projectName;
310
+ els.infoBtn.hidden = false;
311
+ els.intro.hidden = false;
312
+ const strong = (t) => el("strong", { textContent: t });
313
+ const more = el("button", { type: "button", textContent: "About this chat" });
314
+ more.onclick = openSheet;
315
+ els.intro.replaceChildren(
316
+ name ? strong(name) : "Someone",
317
+ " wants your take on ",
318
+ strong(projectName),
319
+ `. ${agentName()} can read the code but can't change anything, and everything here is saved for ${name || "them"}${relay ? ", end-to-end encrypted" : ""}. `,
320
+ more,
321
+ );
322
+ els.dock.hidden = !session;
323
+ if (session) paintComposer();
324
+ }
325
+
326
+ function paintComposer() {
327
+ if (owner || ended) return;
328
+ const out = repliesLeft === 0;
329
+ els.composer.hidden = out;
330
+ els.paused.hidden = !out;
331
+ els.paused.textContent = out ? `This session has used all its replies. ${ownerLabel()[0].toUpperCase() + ownerLabel().slice(1)} can add more, and then you can keep going here.` : "";
332
+ // The only line under the composer: a counter, once you're near the limit.
333
+ const length = els.input.value.trim().length;
334
+ els.hints.hidden = out || length <= maxText * 0.8;
335
+ els.hintLeft.textContent = "";
336
+ els.hintRight.className = length > maxText ? "over" : "warn";
337
+ els.hintRight.textContent = `${length.toLocaleString()} / ${maxText.toLocaleString()} characters${length > maxText ? ", too long to send" : ""}`;
338
+ updateSend();
339
+ paintStatus();
340
+ }
341
+
342
+ function showEnded() {
343
+ showNote("This parley has ended", "Thanks for the feedback. The conversation isn't available here anymore.");
344
+ }
345
+
346
+ // Replaces the conversation with a note, for good.
347
+ function showNote(title, text) {
348
+ if (ended) return;
349
+ ended = true;
350
+ clearLive();
351
+ banner("");
352
+ els.status.hidden = true;
353
+ els.chip.hidden = true;
354
+ els.layout.textContent = "";
355
+ els.layout.append(el("div", { className: "lost inline", style: "flex:1" }, el("div", {}, markEl(), el("h1", { textContent: title }), el("p", { textContent: text }))));
356
+ }
357
+
358
+ function markEl() {
359
+ const span = el("span", { className: "mark" });
360
+ span.innerHTML = `<svg viewBox="4 4 24 24"><g fill="currentColor" stroke="none"><circle cx="8" cy="10" r="2.2"/><rect x="12" y="8" width="12" height="4" rx="2"/><rect x="6" y="14" width="20" height="4" rx="2" opacity=".7"/><rect x="6" y="20" width="13" height="4" rx="2" opacity=".7"/></g></svg>`;
361
+ return span;
362
+ }
363
+
364
+ function banner(text) {
365
+ els.banner.hidden = !text;
366
+ els.banner.textContent = text || "";
367
+ }
368
+
369
+ // ---------- your session view ----------
370
+
371
+ let pendingConfirm = null;
372
+
373
+ async function refreshSide() {
374
+ if (!owner) return;
375
+ try {
376
+ const res = await fetch("/api/panel", { cache: "no-store" });
377
+ const data = await res.json();
378
+ const fresh = data.sessions.find((s) => s.id === session.id);
379
+ if (fresh) (session = fresh), paintSide();
380
+ } catch {}
381
+ }
382
+
383
+ function paintSide() {
384
+ const s = session;
385
+ els.side.hidden = false;
386
+ els.crumbs.innerHTML = "";
387
+ els.crumbs.append(
388
+ el("a", { className: "product", href: "/", textContent: "parley" }),
389
+ el("span", { className: "slash", textContent: "/" }),
390
+ el("a", { className: "product", href: "/", textContent: "Control panel" }),
391
+ el("span", { className: "slash", textContent: "/" }),
392
+ el("span", { className: "here", textContent: `${s.tester} on ${s.project}` }),
393
+ );
394
+ document.title = `${s.tester} on ${s.project} · parley`;
395
+ const used = s.replyLimit - s.repliesLeft;
396
+ const pct = Math.min(100, Math.round((used / s.replyLimit) * 100));
397
+ const kv = (pairs) => el("dl", { className: "kv" }, ...pairs.flatMap(([k, v]) => [el("dt", { textContent: k }), el("dd", { textContent: v })]));
398
+ const ago = (iso) => (iso ? `${clock(iso)}, ${new Date(iso).toLocaleDateString([], { month: "short", day: "numeric" })}` : "not yet");
399
+
400
+ const limitInput = el("input", { type: "number", min: "1", value: String(s.replyLimit), style: "width:80px;height:32px;border:1px solid var(--line-strong);border-radius:8px;padding:0 8px;background:var(--bg)" });
401
+ const setLimit = async (limit) => {
402
+ await post(`/api/sessions/${s.id}/limit`, { limit });
403
+ refreshSide();
404
+ };
405
+ const open = s.status === "open";
406
+ const linkInput = el("input", { value: s.link, readOnly: true });
407
+ linkInput.addEventListener("focus", () => linkInput.select());
408
+ const copy = el("button", { className: "btn", type: "button", textContent: "Copy" });
409
+ copy.onclick = async () => {
410
+ await navigator.clipboard.writeText(s.link);
411
+ copy.textContent = "Copied";
412
+ setTimeout(() => (copy.textContent = "Copy"), 1500);
413
+ };
414
+
415
+ const kill = confirmButton(open ? "End session" : "Delete", open ? "End it for good?" : "Erase everything?", async () => {
416
+ await post(`/api/sessions/${s.id}/${open ? "end" : "delete"}`);
417
+ if (open) refreshSide();
418
+ else location.href = "/";
419
+ });
420
+
421
+ els.side.textContent = "";
422
+ els.side.append(
423
+ el("h2", { textContent: s.tester }),
424
+ el("div", { className: "sub", textContent: `${s.project} · ${s.agent === "codex" ? "Codex" : "Claude"} · ${open ? (s.busy ? "replying now" : "open") : "ended"}` }),
425
+ el(
426
+ "section",
427
+ {},
428
+ el("div", { className: "label", textContent: "Replies" }),
429
+ el("div", { className: `meter ${s.repliesLeft === 0 ? "full" : ""}` }, el("span", { style: `width:${pct}%` })),
430
+ el("div", { className: "cell-muted", textContent: s.repliesLeft === 0 ? `All ${s.replyLimit} used. The tester is paused until you add more.` : `${used} of ${s.replyLimit} used, ${s.repliesLeft} left` }),
431
+ open
432
+ ? el(
433
+ "div",
434
+ { className: "actions" },
435
+ Object.assign(el("button", { className: "btn", type: "button", textContent: "+50 replies" }), { onclick: () => setLimit(s.replyLimit + 50) }),
436
+ limitInput,
437
+ Object.assign(el("button", { className: "btn ghost", type: "button", textContent: "Set" }), { onclick: () => setLimit(Number(limitInput.value)) }),
438
+ )
439
+ : null,
440
+ ),
441
+ el(
442
+ "section",
443
+ {},
444
+ kv([
445
+ ["Messages from tester", String(s.stats.tester_messages)],
446
+ ["Model", s.model ?? "known after the first reply"],
447
+ ["Spend", s.stats.cost_usd ? `$${s.stats.cost_usd.toFixed(2)} at API prices` : s.agent === "codex" ? "not reported by Codex" : "$0.00"],
448
+ ["Last heard from", ago(s.stats.last_tester_at)],
449
+ ["Started", ago(s.createdAt)],
450
+ ["Session", s.id],
451
+ ]),
452
+ ),
453
+ open ? el("section", {}, el("div", { className: "label", textContent: "Tester link" }), el("div", { className: "linkbox", style: "margin-top:8px" }, linkInput, copy)) : null,
454
+ el("section", {}, el("div", { className: "label", textContent: "Brief" }), el("div", { className: "brief-text", style: "margin-top:6px", textContent: s.brief })),
455
+ el("section", {}, el("div", { className: "actions", style: "margin-top:0" }, kill), el("div", { className: "cell-muted", style: "margin-top:8px", textContent: open ? "Ending kills the link for good. You keep the transcript." : "Deleting erases the transcript and screenshots." })),
456
+ );
457
+ paintStatus();
458
+ }
459
+
460
+ // A button that asks once more in place, instead of a browser dialog.
461
+ function confirmButton(label, question, action) {
462
+ const button = el("button", { className: "btn danger", type: "button", textContent: label });
463
+ button.onclick = async () => {
464
+ if (pendingConfirm !== button) {
465
+ pendingConfirm = button;
466
+ button.textContent = question;
467
+ button.className = "btn confirm";
468
+ setTimeout(() => {
469
+ if (pendingConfirm === button) (pendingConfirm = null), (button.textContent = label), (button.className = "btn danger");
470
+ }, 4000);
471
+ return;
472
+ }
473
+ pendingConfirm = null;
474
+ button.disabled = true;
475
+ await action();
476
+ };
477
+ return button;
478
+ }
479
+
480
+ async function post(path, body) {
481
+ const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body ?? {}) });
482
+ if (!res.ok) banner(((await res.json().catch(() => null)) ?? {}).message || "That didn't work.");
483
+ return res;
484
+ }
485
+
486
+ // ---------- talking to the conversation ----------
487
+ //
488
+ // Local links and your own view talk plain HTTP to this computer. parley.im
489
+ // links talk through parley.im over one WebSocket, with every request,
490
+ // reply, update and screenshot sealed with the key from the link's "#".
491
+
492
+ const problemError = (status, code, message) => Object.assign(new Error(message), { status, code });
493
+ const offlineError = () => Object.assign(new Error(`${ownerLabel()[0].toUpperCase() + ownerLabel().slice(1)}'s computer is offline right now.`), { offline: true });
494
+
495
+ function httpTransport() {
496
+ let h;
497
+ let epoch = null;
498
+ let cursor = 0;
499
+ const problemFrom = async (res) => {
500
+ const body = await res.json().catch(() => ({}));
501
+ return problemError(res.status, body.error, body.message || `Something went wrong (${res.status}).`);
502
+ };
503
+ async function load() {
504
+ const res = await fetch(`${base}/api/state`, { cache: "no-store" });
505
+ if (res.status === 410) return h.ended();
506
+ if (!res.ok) throw new Error(`state ${res.status}`);
507
+ const state = await res.json();
508
+ epoch = state.epoch;
509
+ cursor = state.seq;
510
+ h.state(state);
511
+ }
512
+ // Long polling: each request returns as soon as anything changes.
513
+ async function poll() {
514
+ let failures = 0;
515
+ while (!ended) {
516
+ try {
517
+ const res = await fetch(`${base}/api/updates?after=${cursor}&epoch=${epoch}`, { cache: "no-store", signal: AbortSignal.timeout(40_000) });
518
+ if (res.status === 410) return h.ended();
519
+ if (!res.ok) throw new Error(`updates ${res.status}`);
520
+ const data = await res.json();
521
+ failures = 0;
522
+ h.offline(false);
523
+ if (data.reset) {
524
+ await load();
525
+ continue;
526
+ }
527
+ for (const event of data.events) h.event(event);
528
+ cursor = data.seq;
529
+ } catch {
530
+ failures++;
531
+ if (failures > 1) h.offline(true);
532
+ await new Promise((r) => setTimeout(r, Math.min(1000 * failures, 5000)));
533
+ }
534
+ }
535
+ }
536
+ return {
537
+ start(handlers) {
538
+ h = handlers;
539
+ load()
540
+ .catch(() => banner("Couldn't load this session. Try refreshing."))
541
+ .finally(poll);
542
+ },
543
+ async history(before) {
544
+ const res = await fetch(`${base}/api/history?before=${before}`, { cache: "no-store" });
545
+ if (!res.ok) throw await problemFrom(res);
546
+ return res.json();
547
+ },
548
+ async send(text, images) {
549
+ const res = await fetch(`${base}/api/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, images }) });
550
+ if (!res.ok) throw await problemFrom(res);
551
+ return res.json();
552
+ },
553
+ async upload(file) {
554
+ const res = await fetch(`${base}/api/uploads`, { method: "POST", headers: { "content-type": file.type }, body: file });
555
+ if (!res.ok) throw await problemFrom(res);
556
+ return (await res.json()).name;
557
+ },
558
+ image: async (src) => src,
559
+ };
560
+ }
561
+
562
+ function relayTransport() {
563
+ const S = window.ParleySeal;
564
+ const token = base.split("/").pop();
565
+ const pending = new Map();
566
+ const images = new Map();
567
+ let h;
568
+ let key = null;
569
+ let ws = null;
570
+ let epoch = null;
571
+ let cursor = 0;
572
+ let loading = null;
573
+ let early = [];
574
+ let failures = 0;
575
+
576
+ const RELAY_ERRORS = {
577
+ ended: "This parley has ended.",
578
+ slow_down: "That's a lot of messages very fast. Give it a few seconds.",
579
+ stale: "That didn't go through. Try again.",
580
+ bad_request: "This link's key doesn't match the conversation. Ask for the link again.",
581
+ };
582
+
583
+ async function request(op, params = {}, timeoutMs = 30_000) {
584
+ if (ws?.readyState !== WebSocket.OPEN) throw offlineError();
585
+ const r = crypto.randomUUID();
586
+ const d = await S.seal(key, token, "req", { op, r, at: Date.now(), ...params });
587
+ return new Promise((resolve, reject) => {
588
+ const timer = setTimeout(() => {
589
+ pending.delete(r);
590
+ reject(problemError(504, "timeout", `No answer from ${ownerLabel()}'s computer. Try again in a moment.`));
591
+ }, timeoutMs);
592
+ pending.set(r, { resolve, reject, timer });
593
+ ws.send(JSON.stringify({ t: "req", r, d }));
594
+ });
595
+ }
596
+
597
+ async function load() {
598
+ if (loading) return loading;
599
+ loading = (async () => {
600
+ try {
601
+ const state = await request("state");
602
+ epoch = state.epoch;
603
+ cursor = state.seq;
604
+ h.state(state);
605
+ // Updates that raced the state: keep only the newer ones.
606
+ for (const item of early) if (item.epoch === epoch && item.seq > cursor) (cursor = item.seq), h.event(item.event);
607
+ } catch (error) {
608
+ if (error.code === "ended") h.ended();
609
+ else if (error.offline) h.offline(true);
610
+ else if (error.code === "bad_request") h.badKey();
611
+ } finally {
612
+ early = [];
613
+ loading = null;
614
+ }
615
+ })();
616
+ return loading;
617
+ }
618
+
619
+ async function onFrame(raw) {
620
+ const m = JSON.parse(raw);
621
+ if (m.t === "res") {
622
+ const p = pending.get(m.r);
623
+ if (!p) return;
624
+ pending.delete(m.r);
625
+ clearTimeout(p.timer);
626
+ if (m.offline) return p.reject(offlineError());
627
+ if (m.error) return p.reject(problemError(m.error === "ended" ? 410 : 400, m.error, RELAY_ERRORS[m.error] ?? "That didn't work."));
628
+ const body = await S.open(key, token, "res", m.d);
629
+ return body.ok ? p.resolve(body.result) : p.reject(problemError(body.status, body.error, body.message));
630
+ }
631
+ if (m.t === "evt") {
632
+ const item = await S.open(key, token, "evt", m.d);
633
+ if (loading) return early.push(item);
634
+ if (item.epoch !== epoch) return load();
635
+ if (item.seq > cursor) (cursor = item.seq), h.event(item.event);
636
+ return;
637
+ }
638
+ if (m.t === "host") {
639
+ h.offline(!m.on);
640
+ if (m.on) load();
641
+ return;
642
+ }
643
+ if (m.t === "ended") h.ended();
644
+ }
645
+
646
+ function connect() {
647
+ ws = new WebSocket(`${location.origin.replace(/^http/, "ws")}${base}/ws`);
648
+ ws.onopen = () => (failures = 0);
649
+ ws.onmessage = (event) => onFrame(event.data).catch((error) => console.error(error));
650
+ ws.onclose = (event) => {
651
+ for (const p of pending.values()) clearTimeout(p.timer), p.reject(offlineError());
652
+ pending.clear();
653
+ if (event.code === 4410) return h.ended();
654
+ if (ended) return;
655
+ failures++;
656
+ if (failures > 1) h.offline(true);
657
+ setTimeout(connect, Math.min(1000 * failures, 10_000));
658
+ };
659
+ }
660
+
661
+ return {
662
+ async start(handlers) {
663
+ h = handlers;
664
+ try {
665
+ key = await S.importKey(location.hash.slice(1));
666
+ } catch {
667
+ return h.missingKey();
668
+ }
669
+ connect();
670
+ },
671
+ history: (before) => request("history", { before }),
672
+ send: (text, images) => request("send", { text, images }),
673
+ async upload(file) {
674
+ const sealed = await S.sealBytes(key, token, "blob", new Uint8Array(await file.arrayBuffer()));
675
+ const res = await fetch(`${base}/blobs`, { method: "PUT", body: sealed });
676
+ const body = await res.json().catch(() => ({}));
677
+ if (!res.ok) throw problemError(res.status, body.error, body.message || "Couldn't upload that image.");
678
+ return { id: body.id, type: file.type };
679
+ },
680
+ image(ref) {
681
+ if (!images.has(ref.id)) {
682
+ images.set(
683
+ ref.id,
684
+ (async () => {
685
+ // Screenshots live on the sender's computer, so one may not load
686
+ // while it's offline; forget the failure and try again later.
687
+ const res = await fetch(`${base}/blobs/${ref.id}`);
688
+ if (!res.ok) throw new Error(`screenshot ${res.status}`);
689
+ const plain = await S.openBytes(key, token, "blob", new Uint8Array(await res.arrayBuffer()));
690
+ return URL.createObjectURL(new Blob([plain], { type: ref.type }));
691
+ })().catch((error) => {
692
+ images.delete(ref.id);
693
+ throw error;
694
+ }),
695
+ );
696
+ }
697
+ return images.get(ref.id);
698
+ },
699
+ };
700
+ }
701
+
702
+ const transport = relay ? relayTransport() : httpTransport();
703
+
704
+ function showState(state) {
705
+ session = state.session;
706
+ ownerName = state.ownerName;
707
+ repliesLeft = state.repliesLeft;
708
+ more = state.more;
709
+ els.older.hidden = !more;
710
+ els.messages.textContent = "";
711
+ seen.clear();
712
+ live = null;
713
+ for (const m of state.messages) addMessage(m);
714
+ if (state.live) showLive(state.live);
715
+ if (owner) paintSide();
716
+ else paintTester();
717
+ banner("");
718
+ toEnd();
719
+ }
720
+
721
+ function setOffline(on) {
722
+ if (on === offline) return;
723
+ offline = on;
724
+ paintStatus();
725
+ if (on) {
726
+ const whose = ownerName ? `${ownerName}'s` : sender ? `${sender.name.split(" ")[0]}'s` : "The sender's";
727
+ banner(`${whose} computer is offline right now, so nothing can be sent or loaded. This page will reconnect on its own.`);
728
+ } else banner("");
729
+ }
730
+
731
+ // A live update: a new message, the reply streaming in, what the agent is
732
+ // looking at (your view only), replies left, or the end.
733
+ function apply(event) {
734
+ if (event.type === "message") addMessage(event.message);
735
+ else if (event.type === "turn") event.live ? showLive(event.live) : clearLive();
736
+ else if (event.type === "delta") {
737
+ if (!live) showLive({ text: "", activity: null });
738
+ live.text += event.text;
739
+ paintLive();
740
+ } else if (event.type === "activity") {
741
+ if (!live) showLive({ text: "", activity: null });
742
+ live.steps.push(event.line);
743
+ paintLive();
744
+ } else if (event.type === "replies") {
745
+ repliesLeft = event.left;
746
+ if (!owner && event.model && session) session.model = event.model;
747
+ if (owner) refreshSide();
748
+ else paintComposer();
749
+ } else if (event.type === "ended") {
750
+ if (owner) refreshSide();
751
+ else showEnded();
752
+ }
753
+ }
754
+
755
+ // ---------- composing ----------
756
+
757
+ function updateSend() {
758
+ const length = els.input.value.trim().length;
759
+ const ready = !attachments.some((a) => a.uploading);
760
+ els.send.disabled = sending || !ready || length > maxText || (!length && !attachments.length);
761
+ }
762
+
763
+ // Browsers with field-sizing size the box themselves (no jumping while you
764
+ // dictate); others get measured. CSS caps it either way.
765
+ const nativeSizing = window.CSS?.supports?.("field-sizing", "content");
766
+ function autosize() {
767
+ if (nativeSizing) return;
768
+ els.input.style.height = "auto";
769
+ els.input.style.height = `${els.input.scrollHeight}px`;
770
+ }
771
+
772
+ function paintAttachments() {
773
+ els.attachments.hidden = !attachments.length;
774
+ els.attachments.textContent = "";
775
+ for (const a of attachments) {
776
+ const x = el("button", { type: "button", textContent: "×", title: "Remove" });
777
+ x.onclick = () => {
778
+ attachments = attachments.filter((b) => b !== a);
779
+ paintAttachments();
780
+ };
781
+ els.attachments.append(el("div", { className: `thumb ${a.uploading ? "loading" : ""}` }, el("img", { src: a.preview, alt: "" }), x));
782
+ }
783
+ updateSend();
784
+ }
785
+
786
+ async function addFiles(files) {
787
+ for (const file of files) {
788
+ if (!/^image\/(png|jpeg|gif|webp)$/.test(file.type)) {
789
+ banner("Only images can be added: PNG, JPEG, GIF or WebP.");
790
+ continue;
791
+ }
792
+ if (attachments.length >= 6) break;
793
+ const a = { handle: null, preview: URL.createObjectURL(file), uploading: true };
794
+ attachments.push(a);
795
+ paintAttachments();
796
+ try {
797
+ a.handle = await transport.upload(file);
798
+ a.uploading = false;
799
+ } catch (error) {
800
+ attachments = attachments.filter((b) => b !== a);
801
+ if (error.status === 410) return showEnded();
802
+ banner(`Couldn't add that image. ${error.message}`);
803
+ }
804
+ paintAttachments();
805
+ }
806
+ }
807
+
808
+ async function submit(e) {
809
+ e?.preventDefault?.();
810
+ if (els.send.disabled) return;
811
+ const text = els.input.value.trim();
812
+ const images = attachments.filter((a) => a.handle).map((a) => a.handle);
813
+ sending = true;
814
+ updateSend();
815
+ try {
816
+ const { message } = await transport.send(text, images);
817
+ addMessage(message);
818
+ toEnd(); // you just spoke: back to the bottom, and follow from here
819
+ els.input.value = "";
820
+ attachments = [];
821
+ paintAttachments();
822
+ autosize();
823
+ banner("");
824
+ } catch (error) {
825
+ if (error.status === 410) return showEnded();
826
+ if (error.code === "out_of_replies") {
827
+ repliesLeft = 0;
828
+ return;
829
+ }
830
+ banner(`${error.message} Your text is still in the box.`);
831
+ } finally {
832
+ sending = false;
833
+ paintComposer();
834
+ if (!touch) els.input.focus();
835
+ }
836
+ }
837
+
838
+ function lightbox(src) {
839
+ const div = el("div", { className: "lightbox" }, el("img", { src, alt: "Screenshot" }));
840
+ div.onclick = () => div.remove();
841
+ document.body.append(div);
842
+ }
843
+
844
+ els.input.addEventListener("input", () => {
845
+ autosize();
846
+ paintComposer();
847
+ });
848
+ els.input.addEventListener("keydown", (e) => {
849
+ // On phones Return makes a new line; the send button sends.
850
+ if (e.key === "Enter" && !e.shiftKey && !touch && !e.isComposing) submit(e);
851
+ });
852
+ els.input.addEventListener("paste", (e) => {
853
+ const files = [...(e.clipboardData?.files || [])];
854
+ if (files.length) {
855
+ e.preventDefault();
856
+ addFiles(files);
857
+ }
858
+ });
859
+ els.file.addEventListener("change", () => {
860
+ addFiles([...els.file.files]);
861
+ els.file.value = "";
862
+ });
863
+ els.send.addEventListener("click", submit);
864
+
865
+ if (!owner) {
866
+ let depth = 0;
867
+ addEventListener("dragenter", (e) => {
868
+ if (![...(e.dataTransfer?.types || [])].includes("Files") || ended || repliesLeft === 0) return;
869
+ depth++;
870
+ els.drop.hidden = false;
871
+ });
872
+ addEventListener("dragleave", () => {
873
+ if (--depth <= 0) (depth = 0), (els.drop.hidden = true);
874
+ });
875
+ addEventListener("dragover", (e) => e.preventDefault());
876
+ addEventListener("drop", (e) => {
877
+ e.preventDefault();
878
+ depth = 0;
879
+ els.drop.hidden = true;
880
+ if (!ended && repliesLeft !== 0) addFiles([...(e.dataTransfer?.files || [])]);
881
+ });
882
+ }
883
+
884
+ let started = false;
885
+ if (relay) paintTester();
886
+ transport.start({
887
+ state(state) {
888
+ showState(state);
889
+ if (started) return;
890
+ started = true;
891
+ if (owner) setInterval(refreshSide, 5000);
892
+ else if (!touch) els.input.focus();
893
+ },
894
+ event: apply,
895
+ offline: setOffline,
896
+ ended: showEnded,
897
+ missingKey: () =>
898
+ showNote("This link is incomplete", "The part after the # is missing, and it's the key to this conversation. Ask whoever sent it to send the whole link again."),
899
+ badKey: () => showNote("This link doesn't open the conversation", "Its key doesn't match. Ask whoever sent it to send the whole link again."),
900
+ });
901
+ })();