@parley-im/parley 0.1.2 → 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/web/chat.js CHANGED
@@ -10,7 +10,7 @@
10
10
  chip: $("chip"), infoBtn: $("info-btn"), sheet: $("sheet"), sheetBack: $("sheet-back"), sheetBody: $("sheet-body"),
11
11
  sheetClose: $("sheet-close"), app: document.querySelector(".app"),
12
12
  };
13
- if (!owner) document.body.classList.add("tester-view");
13
+ if (!owner) document.body.classList.add("guest-view");
14
14
 
15
15
  let session = null;
16
16
  let ownerName = null;
@@ -21,6 +21,7 @@
21
21
  let live = null; // { el, text, steps, bodyEl, stepsEl }
22
22
  let attachments = []; // { handle, preview, uploading }
23
23
  let sending = false;
24
+ let draftId = null; // see submit()
24
25
  let ended = false;
25
26
  let offline = false;
26
27
 
@@ -109,34 +110,42 @@
109
110
  if (scroller.clientHeight !== viewHeight) viewResized();
110
111
  }).observe(scroller);
111
112
 
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.
113
+ // Phones show the keyboard over the page and then scroll the page to keep
114
+ // the box in view, which pushes the header off the top and leaves empty
115
+ // space above the composer (Safari, and apps' in-app browsers). So the app
116
+ // is always pinned to exactly the visible area, its size and its offset,
117
+ // keyboard or not, and any scrolling of the page itself is undone. Desktop
118
+ // browsers see no difference.
116
119
  const vv = window.visualViewport;
117
120
  if (vv) {
121
+ let queued = false;
118
122
  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");
123
+ queued = false;
124
+ Object.assign(els.app.style, {
125
+ position: "fixed",
126
+ left: "0",
127
+ right: "0",
128
+ top: "0",
129
+ height: `${vv.height}px`,
130
+ transform: vv.offsetTop ? `translateY(${vv.offsetTop}px)` : "",
131
+ });
132
+ if (window.scrollY) window.scrollTo(0, 0);
133
+ };
134
+ const soon = () => {
135
+ if (!queued) (queued = true), requestAnimationFrame(fit);
129
136
  };
137
+ fit();
130
138
  vv.addEventListener("resize", fit);
131
- vv.addEventListener("scroll", fit);
139
+ vv.addEventListener("scroll", soon);
132
140
  addEventListener("resize", fit);
141
+ addEventListener("scroll", soon, { passive: true });
133
142
  }
134
143
 
135
144
  // ---------- messages ----------
136
145
 
137
146
  function messageEl(m) {
138
147
  const time = clock(m.createdAt);
139
- const body = m.role === "tester" && m.body === "(screenshot)" && m.images.length ? null : el("div", { className: "body" });
148
+ const body = m.role === "guest" && m.body === "(screenshot)" && m.images.length ? null : el("div", { className: "body" });
140
149
  if (body) body.innerHTML = window.renderMarkdown(m.body);
141
150
  const shots = m.images.length
142
151
  ? el(
@@ -156,11 +165,12 @@
156
165
  )
157
166
  : null;
158
167
 
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}` }));
168
+ if (m.role === "guest") {
169
+ const who = owner ? session.guest : "You";
170
+ return el("li", { className: "msg guest" }, el("div", { className: "bubble" }, body, shots), el("div", { className: "stamp", textContent: `${who} · ${time}` }));
162
171
  }
163
- if (m.role === "error") return el("li", { className: "msg error" }, body);
172
+ // Your own view says why a reply failed; guests just get the apology.
173
+ if (m.role === "error") return el("li", { className: "msg error" }, body, m.detail ? el("div", { className: "detail mono", textContent: m.detail }) : null);
164
174
  const meta = el("div", { className: "meta" }, el("span", { className: "who", textContent: agentName() }), ` · ${time}`);
165
175
  return el("li", { className: "msg agent" }, meta, body, shots, owner && m.turn ? trailEl(m.turn) : null);
166
176
  }
@@ -187,7 +197,7 @@
187
197
  if (prepend) els.messages.prepend(node);
188
198
  else if (live?.el.isConnected) els.messages.insertBefore(node, live.el);
189
199
  else els.messages.append(node);
190
- if (m.role !== "tester" && !prepend) clearLive();
200
+ if (m.role !== "guest" && !prepend) clearLive();
191
201
  }
192
202
 
193
203
  function showLive(state) {
@@ -254,20 +264,22 @@
254
264
  let dot = "on";
255
265
  let label = owner ? `${agentName()} · live` : speaker();
256
266
  if (offline) (dot = "warn"), (label = "reconnecting");
257
- else if (live) (dot = "busy"), (label = owner ? `${agentName()} is replying` : "replying…");
267
+ // A reply in progress shows as typing dots in the conversation; the
268
+ // header stays put.
269
+ else if (live && owner) (dot = "busy"), (label = `${agentName()} is replying`);
258
270
  else if (!owner && repliesLeft === 0) (dot = "warn"), (label = "paused");
259
271
  else if (owner && session.status !== "open") (dot = "off"), (label = "ended");
260
272
  els.statusDot.className = `dot ${dot}`;
261
273
  els.statusText.textContent = label;
262
274
  if (!owner && repliesLeft != null) {
263
275
  els.chip.hidden = false;
264
- els.chip.textContent = `${repliesLeft} left`;
276
+ els.chip.textContent = `${repliesLeft}/${session.replyLimit}`;
265
277
  els.chip.title = `${repliesLeft} of ${session.replyLimit} replies left in this chat`;
266
278
  els.chip.className = `chip ${repliesLeft <= 10 ? "low" : ""}`;
267
279
  }
268
280
  }
269
281
 
270
- // Everything a tester might wonder about, behind the info button.
282
+ // Everything a guest might wonder about, behind the info button.
271
283
  function openSheet() {
272
284
  const who = sender?.name || ownerName || "The person who sent this link";
273
285
  const whom = ownerName || "them";
@@ -301,9 +313,9 @@
301
313
  els.sheetBack.addEventListener("click", closeSheet);
302
314
  addEventListener("keydown", (e) => e.key === "Escape" && closeSheet());
303
315
 
304
- // The tester's intro. On parley.im links it can show before the
316
+ // The guest's intro. On parley.im links it can show before the
305
317
  // conversation loads, from what parley.im vouches for.
306
- function paintTester() {
318
+ function paintGuest() {
307
319
  const name = ownerName || sender?.name?.split(" ")[0];
308
320
  const projectName = session?.project ?? knownProject ?? "";
309
321
  els.project.textContent = projectName;
@@ -389,9 +401,9 @@
389
401
  el("span", { className: "slash", textContent: "/" }),
390
402
  el("a", { className: "product", href: "/", textContent: "Control panel" }),
391
403
  el("span", { className: "slash", textContent: "/" }),
392
- el("span", { className: "here", textContent: `${s.tester} on ${s.project}` }),
404
+ el("span", { className: "here", textContent: `${s.guest} on ${s.project}` }),
393
405
  );
394
- document.title = `${s.tester} on ${s.project} · parley`;
406
+ document.title = `${s.guest} on ${s.project} · parley`;
395
407
  const used = s.replyLimit - s.repliesLeft;
396
408
  const pct = Math.min(100, Math.round((used / s.replyLimit) * 100));
397
409
  const kv = (pairs) => el("dl", { className: "kv" }, ...pairs.flatMap(([k, v]) => [el("dt", { textContent: k }), el("dd", { textContent: v })]));
@@ -420,14 +432,14 @@
420
432
 
421
433
  els.side.textContent = "";
422
434
  els.side.append(
423
- el("h2", { textContent: s.tester }),
435
+ el("h2", { textContent: s.guest }),
424
436
  el("div", { className: "sub", textContent: `${s.project} · ${s.agent === "codex" ? "Codex" : "Claude"} · ${open ? (s.busy ? "replying now" : "open") : "ended"}` }),
425
437
  el(
426
438
  "section",
427
439
  {},
428
440
  el("div", { className: "label", textContent: "Replies" }),
429
441
  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` }),
442
+ el("div", { className: "cell-muted", textContent: s.repliesLeft === 0 ? `All ${s.replyLimit} used. The guest is paused until you add more.` : `${used} of ${s.replyLimit} used, ${s.repliesLeft} left` }),
431
443
  open
432
444
  ? el(
433
445
  "div",
@@ -442,15 +454,15 @@
442
454
  "section",
443
455
  {},
444
456
  kv([
445
- ["Messages from tester", String(s.stats.tester_messages)],
457
+ ["Messages from guest", String(s.stats.guest_messages)],
446
458
  ["Model", s.model ?? "known after the first reply"],
447
459
  ["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)],
460
+ ["Last heard from", ago(s.stats.last_guest_at)],
449
461
  ["Started", ago(s.createdAt)],
450
462
  ["Session", s.id],
451
463
  ]),
452
464
  ),
453
- open ? el("section", {}, el("div", { className: "label", textContent: "Tester link" }), el("div", { className: "linkbox", style: "margin-top:8px" }, linkInput, copy)) : null,
465
+ open ? el("section", {}, el("div", { className: "label", textContent: "Guest link" }), el("div", { className: "linkbox", style: "margin-top:8px" }, linkInput, copy)) : null,
454
466
  el("section", {}, el("div", { className: "label", textContent: "Brief" }), el("div", { className: "brief-text", style: "margin-top:6px", textContent: s.brief })),
455
467
  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
468
  );
@@ -491,6 +503,7 @@
491
503
 
492
504
  const problemError = (status, code, message) => Object.assign(new Error(message), { status, code });
493
505
  const offlineError = () => Object.assign(new Error(`${ownerLabel()[0].toUpperCase() + ownerLabel().slice(1)}'s computer is offline right now.`), { offline: true });
506
+ const connectionError = () => Object.assign(new Error("The connection dropped. Reconnecting…"), { offline: true, connection: true });
494
507
 
495
508
  function httpTransport() {
496
509
  let h;
@@ -545,8 +558,8 @@
545
558
  if (!res.ok) throw await problemFrom(res);
546
559
  return res.json();
547
560
  },
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 }) });
561
+ async send(text, images, cid) {
562
+ const res = await fetch(`${base}/api/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, images, cid }) });
550
563
  if (!res.ok) throw await problemFrom(res);
551
564
  return res.json();
552
565
  },
@@ -583,7 +596,7 @@
583
596
  async function request(op, params = {}, timeoutMs = 30_000) {
584
597
  if (ws?.readyState !== WebSocket.OPEN) throw offlineError();
585
598
  const r = crypto.randomUUID();
586
- const d = await S.seal(key, token, "req", { op, r, at: Date.now(), ...params });
599
+ const d = await S.seal(key, token, "req", { op, r, at: Date.now() + clock, ...params });
587
600
  return new Promise((resolve, reject) => {
588
601
  const timer = setTimeout(() => {
589
602
  pending.delete(r);
@@ -606,7 +619,7 @@
606
619
  for (const item of early) if (item.epoch === epoch && item.seq > cursor) (cursor = item.seq), h.event(item.event);
607
620
  } catch (error) {
608
621
  if (error.code === "ended") h.ended();
609
- else if (error.offline) h.offline(true);
622
+ else if (error.offline) h.offline(true, error.connection ? "connection" : "computer");
610
623
  else if (error.code === "bad_request") h.badKey();
611
624
  } finally {
612
625
  early = [];
@@ -625,7 +638,7 @@
625
638
  clearTimeout(p.timer);
626
639
  if (m.offline) return p.reject(offlineError());
627
640
  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);
641
+ const body = await S.open(key, token, `res:${m.r}`, m.d);
629
642
  return body.ok ? p.resolve(body.result) : p.reject(problemError(body.status, body.error, body.message));
630
643
  }
631
644
  if (m.t === "evt") {
@@ -636,6 +649,7 @@
636
649
  return;
637
650
  }
638
651
  if (m.t === "host") {
652
+ if (typeof m.now === "number") clock = m.now - Date.now();
639
653
  h.offline(!m.on);
640
654
  if (m.on) load();
641
655
  return;
@@ -643,21 +657,43 @@
643
657
  if (m.t === "ended") h.ended();
644
658
  }
645
659
 
660
+ // Frames are handled one at a time, in order: decrypting is asynchronous,
661
+ // and a later update finishing first would make an earlier one look old.
662
+ let frames = Promise.resolve();
663
+ // parley.im's clock minus this phone's, so a request's age is judged the
664
+ // same way at both ends whatever time the phone thinks it is.
665
+ let clock = 0;
666
+ let retry = null;
667
+
646
668
  function connect() {
669
+ clearTimeout(retry);
670
+ retry = null;
647
671
  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));
672
+ ws.onopen = () => {
673
+ failures = 0;
674
+ h.offline(false, "connection");
675
+ };
676
+ ws.onmessage = (event) => (frames = frames.then(() => onFrame(event.data)).catch((error) => console.error(error)));
650
677
  ws.onclose = (event) => {
651
- for (const p of pending.values()) clearTimeout(p.timer), p.reject(offlineError());
678
+ for (const p of pending.values()) clearTimeout(p.timer), p.reject(connectionError());
652
679
  pending.clear();
653
680
  if (event.code === 4410) return h.ended();
654
681
  if (ended) return;
655
682
  failures++;
656
- if (failures > 1) h.offline(true);
657
- setTimeout(connect, Math.min(1000 * failures, 10_000));
683
+ // This page lost its own connection (a phone switching networks, or
684
+ // waking up); the sender's computer may be fine.
685
+ if (failures > 1) h.offline(true, "connection");
686
+ retry = setTimeout(connect, Math.min(1000 * failures, 10_000));
658
687
  };
659
688
  }
660
689
 
690
+ // Back from the background or back online: reconnect now, not on a timer.
691
+ const wake = () => {
692
+ if (!ended && document.visibilityState === "visible" && retry) connect();
693
+ };
694
+ document.addEventListener("visibilitychange", wake);
695
+ window.addEventListener("online", wake);
696
+
661
697
  return {
662
698
  async start(handlers) {
663
699
  h = handlers;
@@ -669,7 +705,7 @@
669
705
  connect();
670
706
  },
671
707
  history: (before) => request("history", { before }),
672
- send: (text, images) => request("send", { text, images }),
708
+ send: (text, images, cid) => request("send", { text, images, cid }),
673
709
  async upload(file) {
674
710
  const sealed = await S.sealBytes(key, token, "blob", new Uint8Array(await file.arrayBuffer()));
675
711
  const res = await fetch(`${base}/blobs`, { method: "PUT", body: sealed });
@@ -686,7 +722,7 @@
686
722
  // while it's offline; forget the failure and try again later.
687
723
  const res = await fetch(`${base}/blobs/${ref.id}`);
688
724
  if (!res.ok) throw new Error(`screenshot ${res.status}`);
689
- const plain = await S.openBytes(key, token, "blob", new Uint8Array(await res.arrayBuffer()));
725
+ const plain = await S.openBytes(key, token, `shot:${ref.id}`, new Uint8Array(await res.arrayBuffer()));
690
726
  return URL.createObjectURL(new Blob([plain], { type: ref.type }));
691
727
  })().catch((error) => {
692
728
  images.delete(ref.id);
@@ -713,16 +749,22 @@
713
749
  for (const m of state.messages) addMessage(m);
714
750
  if (state.live) showLive(state.live);
715
751
  if (owner) paintSide();
716
- else paintTester();
752
+ else paintGuest();
717
753
  banner("");
718
754
  toEnd();
719
755
  }
720
756
 
721
- function setOffline(on) {
722
- if (on === offline) return;
757
+ // Offline means one of two things: the sender's computer (asleep, or
758
+ // parley isn't running), or this page's own connection.
759
+ let offlineWhy = null;
760
+ function setOffline(on, why = "computer") {
761
+ if (!on && offlineWhy && why !== offlineWhy) return;
762
+ if (on === offline && (!on || why === offlineWhy)) return;
723
763
  offline = on;
764
+ offlineWhy = on ? why : null;
724
765
  paintStatus();
725
- if (on) {
766
+ if (on && why === "connection") banner("The connection dropped. Reconnecting…");
767
+ else if (on) {
726
768
  const whose = ownerName ? `${ownerName}'s` : sender ? `${sender.name.split(" ")[0]}'s` : "The sender's";
727
769
  banner(`${whose} computer is offline right now, so nothing can be sent or loaded. This page will reconnect on its own.`);
728
770
  } else banner("");
@@ -783,9 +825,28 @@
783
825
  updateSend();
784
826
  }
785
827
 
828
+ // Phone photos are often several MB, more than the agents take, so big
829
+ // ones are scaled down (2000 px on the long side) before they're sent.
830
+ async function shrink(file) {
831
+ if (file.type === "image/gif" || file.size <= 1_500_000) return file;
832
+ try {
833
+ const bitmap = await createImageBitmap(file);
834
+ const scale = Math.min(1, 2000 / Math.max(bitmap.width, bitmap.height));
835
+ const canvas = document.createElement("canvas");
836
+ canvas.width = Math.round(bitmap.width * scale);
837
+ canvas.height = Math.round(bitmap.height * scale);
838
+ canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height);
839
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.85));
840
+ return blob && blob.size < file.size ? new File([blob], "screenshot.jpg", { type: "image/jpeg" }) : file;
841
+ } catch {
842
+ return file;
843
+ }
844
+ }
845
+
786
846
  async function addFiles(files) {
787
- for (const file of files) {
788
- if (!/^image\/(png|jpeg|gif|webp)$/.test(file.type)) {
847
+ for (const original of files) {
848
+ const file = await shrink(original);
849
+ if (!/^image\/(png|jpeg|gif|webp)$/.test(original.type)) {
789
850
  banner("Only images can be added: PNG, JPEG, GIF or WebP.");
790
851
  continue;
791
852
  }
@@ -810,10 +871,14 @@
810
871
  if (els.send.disabled) return;
811
872
  const text = els.input.value.trim();
812
873
  const images = attachments.filter((a) => a.handle).map((a) => a.handle);
874
+ // One id per message you mean to send, kept until it goes through, so
875
+ // sending again after a lost answer can't post it twice.
876
+ draftId ??= crypto.randomUUID();
813
877
  sending = true;
814
878
  updateSend();
815
879
  try {
816
- const { message } = await transport.send(text, images);
880
+ const { message } = await transport.send(text, images, draftId);
881
+ draftId = null;
817
882
  addMessage(message);
818
883
  toEnd(); // you just spoke: back to the bottom, and follow from here
819
884
  els.input.value = "";
@@ -842,6 +907,7 @@
842
907
  }
843
908
 
844
909
  els.input.addEventListener("input", () => {
910
+ draftId = null; // edited: a new message, not a resend
845
911
  autosize();
846
912
  paintComposer();
847
913
  });
@@ -882,7 +948,7 @@
882
948
  }
883
949
 
884
950
  let started = false;
885
- if (relay) paintTester();
951
+ if (relay) paintGuest();
886
952
  transport.start({
887
953
  state(state) {
888
954
  showState(state);
@@ -0,0 +1,41 @@
1
+ // Keeps the parley.im dashboard current: who's on each session's page right
2
+ // now, when they were last active, and whether a reply is coming in. It
3
+ // never shows what was said; parley.im doesn't have that.
4
+ (() => {
5
+ const ago = (ms) => {
6
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
7
+ if (s < 60) return "just now";
8
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
9
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
10
+ return `${Math.round(s / 86400)}d ago`;
11
+ };
12
+ const dot = (kind, text) => {
13
+ const span = document.createElement("span");
14
+ span.className = `dot${kind ? ` ${kind}` : ""}`;
15
+ return [span, document.createTextNode(text)];
16
+ };
17
+ // Matches live() in relay/src/pages.ts.
18
+ const liveNodes = (s) => {
19
+ if (s.online === false) return dot("off", "computer offline");
20
+ if (s.replying) return dot("busy", "replying");
21
+ if (s.here) return dot("on", s.here > 1 ? `${s.here} people here` : "here now");
22
+ if (s.active) return dot("", `active ${ago(s.active)}`);
23
+ return dot("", "not opened yet");
24
+ };
25
+
26
+ async function refresh() {
27
+ if (document.hidden) return;
28
+ try {
29
+ const res = await fetch("/dashboard.json", { cache: "no-store" });
30
+ if (!res.ok) return;
31
+ const { sessions } = await res.json();
32
+ const rows = [...document.querySelectorAll("[data-session]")];
33
+ // A session opened or ended elsewhere: show the page fresh.
34
+ if (rows.map((r) => r.dataset.session).sort().join() !== sessions.map((s) => s.id).sort().join()) return location.reload();
35
+ for (const s of sessions) rows.find((r) => r.dataset.session === s.id)?.querySelector(".live")?.replaceChildren(...liveNodes(s));
36
+ } catch {}
37
+ }
38
+
39
+ setInterval(refresh, 4000);
40
+ document.addEventListener("visibilitychange", refresh);
41
+ })();
package/web/ended.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <meta name="robots" content="noindex, nofollow">
7
7
  <title>This parley has ended</title>
8
- <link rel="icon" href="/static/icon.svg">
8
+ <link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
9
9
  <link rel="stylesheet" href="/static/app.css">
10
10
  </head>
11
11
  <body>
package/web/missing.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <meta name="robots" content="noindex, nofollow">
7
7
  <title>Link not found</title>
8
- <link rel="icon" href="/static/icon.svg">
8
+ <link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
9
9
  <link rel="stylesheet" href="/static/app.css">
10
10
  </head>
11
11
  <body>
package/web/panel.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Control panel · parley</title>
7
- <link rel="icon" href="/static/icon.svg">
7
+ <link rel="icon" href="/static/icon.svg?v=2" type="image/svg+xml">
8
8
  <link rel="stylesheet" href="/static/app.css">
9
9
  </head>
10
10
  <body class="panel">
@@ -20,7 +20,7 @@
20
20
 
21
21
  <div class="toolbar">
22
22
  <div class="tabs" id="tabs" role="tablist"></div>
23
- <input class="search" id="search" type="search" placeholder="Filter by tester or project" aria-label="Filter sessions">
23
+ <input class="search" id="search" type="search" placeholder="Filter by guest or project" aria-label="Filter sessions">
24
24
  <button class="btn primary" id="new" type="button">
25
25
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>New session
26
26
  </button>
@@ -41,8 +41,8 @@
41
41
  <span class="help">The interviewer runs here, read-only.</span>
42
42
  </div>
43
43
  <div class="field">
44
- <label for="f-tester">Who it's for</label>
45
- <input id="f-tester" name="tester" placeholder="Sam" required autocomplete="off">
44
+ <label for="f-guest">Who it's for</label>
45
+ <input id="f-guest" name="guest" placeholder="Sam" required autocomplete="off">
46
46
  </div>
47
47
  <div class="field">
48
48
  <label for="f-brief">Brief</label>
package/web/panel.js CHANGED
@@ -86,7 +86,7 @@
86
86
  const q = query.toLowerCase();
87
87
  const shown = sessions
88
88
  .filter((s) => (tab === "all" ? true : tab === "open" ? s.status === "open" : s.status !== "open"))
89
- .filter((s) => !q || `${s.tester} ${s.project} ${s.projectDir}`.toLowerCase().includes(q));
89
+ .filter((s) => !q || `${s.guest} ${s.project} ${s.projectDir}`.toLowerCase().includes(q));
90
90
 
91
91
  const head = el(
92
92
  "div",
@@ -113,16 +113,16 @@
113
113
  function row(s) {
114
114
  const open = s.status === "open";
115
115
  const used = s.replyLimit - s.repliesLeft;
116
- const name = el("div", { className: "name" }, el("span", { className: `dot ${!open ? "" : s.busy ? "busy" : s.repliesLeft === 0 ? "warn" : "on"}` }), s.tester);
116
+ const name = el("div", { className: "name" }, el("span", { className: `dot ${!open ? "" : s.busy ? "busy" : s.repliesLeft === 0 ? "warn" : "on"}` }), s.guest);
117
117
  if (s.stats.unread) name.append(el("span", { className: "badge", textContent: `${s.stats.unread} new` }));
118
118
  const who = el("a", { className: "who-cell", href: `/o/${s.id}`, style: "text-decoration:none" }, name, el("div", { className: "where", textContent: `${s.project} · ${s.model ?? (s.agent === "codex" ? "Codex" : "Claude")} · ${s.id}`, title: s.projectDir }));
119
119
 
120
120
  const activity = el(
121
121
  "div",
122
122
  { className: "c-activity cell-muted" },
123
- !open ? `ended ${ago(s.endedAt)}` : s.busy ? "replying now" : s.stats.last_tester_at ? `heard from ${ago(s.stats.last_tester_at)}` : "hasn't said anything yet",
123
+ !open ? `ended ${ago(s.endedAt)}` : s.busy ? "replying now" : s.stats.last_guest_at ? `heard from ${ago(s.stats.last_guest_at)}` : "hasn't said anything yet",
124
124
  );
125
- const messages = el("div", { className: "c-messages num", textContent: `${s.stats.tester_messages} message${s.stats.tester_messages === 1 ? "" : "s"}` });
125
+ const messages = el("div", { className: "c-messages num", textContent: `${s.stats.guest_messages} message${s.stats.guest_messages === 1 ? "" : "s"}` });
126
126
  const pct = Math.min(100, Math.round((used / s.replyLimit) * 100));
127
127
  const replies = el(
128
128
  "div",
@@ -207,9 +207,9 @@
207
207
  await navigator.clipboard.writeText(s.link);
208
208
  copy.textContent = "Copied";
209
209
  };
210
- $("made").replaceChildren(el("strong", { textContent: `Session for ${s.tester} is ready.` }), el("div", { className: "linkbox" }, link, copy), el("a", { className: "cell-muted", href: `/o/${s.id}`, textContent: "Open it" }));
210
+ $("made").replaceChildren(el("strong", { textContent: `Session for ${s.guest} is ready.` }), el("div", { className: "linkbox" }, link, copy), el("a", { className: "cell-muted", href: `/o/${s.id}`, textContent: "Open it" }));
211
211
  $("made").hidden = false;
212
- $("f-tester").value = $("f-brief").value = $("f-greeting").value = "";
212
+ $("f-guest").value = $("f-brief").value = $("f-greeting").value = "";
213
213
  refresh();
214
214
  } catch (error) {
215
215
  $("form-error").textContent = error.message;