@parley-im/parley 0.1.1 → 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/README.md +23 -17
- package/bin/parley +7 -0
- package/dist/access.js +57 -0
- package/dist/account.js +5 -1
- package/dist/agents/claude.js +22 -10
- package/dist/agents/codex.js +17 -6
- package/dist/agents/process.js +35 -4
- package/dist/api.js +22 -11
- package/dist/autostart.js +86 -0
- package/dist/charter.js +5 -4
- package/dist/cli.js +64 -17
- package/dist/config.js +53 -12
- package/dist/control.js +16 -10
- package/dist/daemon.js +34 -10
- package/dist/db.js +287 -53
- package/dist/mcp.js +20 -20
- package/dist/models.js +8 -2
- package/dist/relay.js +32 -14
- package/dist/runner.js +15 -13
- package/dist/seal.js +7 -5
- package/dist/server.js +36 -21
- package/dist/state.js +7 -4
- package/dist/transcript.js +24 -11
- package/dist/version.js +15 -0
- package/package.json +2 -2
- package/web/app.css +21 -13
- package/web/chat.html +1 -1
- package/web/chat.js +120 -54
- package/web/dashboard.js +41 -0
- package/web/ended.html +1 -1
- package/web/missing.html +1 -1
- package/web/panel.html +4 -4
- package/web/panel.js +6 -6
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("
|
|
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
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// the
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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",
|
|
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 === "
|
|
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 === "
|
|
160
|
-
const who = owner ? session.
|
|
161
|
-
return el("li", { className: "msg
|
|
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
|
-
|
|
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 !== "
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
404
|
+
el("span", { className: "here", textContent: `${s.guest} on ${s.project}` }),
|
|
393
405
|
);
|
|
394
|
-
document.title = `${s.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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: "
|
|
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,
|
|
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 = () =>
|
|
649
|
-
|
|
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(
|
|
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
|
-
|
|
657
|
-
|
|
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,
|
|
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
|
|
752
|
+
else paintGuest();
|
|
717
753
|
banner("");
|
|
718
754
|
toEnd();
|
|
719
755
|
}
|
|
720
756
|
|
|
721
|
-
|
|
722
|
-
|
|
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
|
|
788
|
-
|
|
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)
|
|
951
|
+
if (relay) paintGuest();
|
|
886
952
|
transport.start({
|
|
887
953
|
state(state) {
|
|
888
954
|
showState(state);
|
package/web/dashboard.js
ADDED
|
@@ -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
|
|
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-
|
|
45
|
-
<input id="f-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
212
|
+
$("f-guest").value = $("f-brief").value = $("f-greeting").value = "";
|
|
213
213
|
refresh();
|
|
214
214
|
} catch (error) {
|
|
215
215
|
$("form-error").textContent = error.message;
|