@loupekit/sdk 0.10.22-next.68 → 0.10.23-next.69

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.
@@ -794,6 +794,43 @@ var Loupe = (() => {
794
794
  .voice:disabled { opacity: .45; cursor: not-allowed; }
795
795
  .voice.on { border-color: var(--pin); color: var(--pin); background: var(--bg-3); }
796
796
 
797
+ /* in-app mentions */
798
+ .hnotif { margin-top: 10px; }
799
+ .nf-head { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; font-size: 11.5px; color: var(--ink); }
800
+ .nf-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }
801
+ .nf-i {
802
+ display: flex; align-items: baseline; gap: 7px; width: 100%; text-align: left;
803
+ padding: 6px 8px; margin-bottom: 4px; border: 1px solid var(--accent); border-radius: 8px;
804
+ background: var(--bg-2); color: var(--ink); font-size: 11px; cursor: pointer;
805
+ }
806
+ .nf-i:hover { background: var(--bg-3); }
807
+ .nf-b { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
808
+ .nf-w { flex: none; color: var(--muted); font-size: 10.5px; }
809
+ .nf-read {
810
+ margin-top: 2px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 7px;
811
+ background: var(--bg); color: var(--muted); font-size: 10.5px; cursor: pointer;
812
+ }
813
+ .nf-read:hover { border-color: var(--accent); color: var(--ink); }
814
+
815
+ /* mention highlighting + autocomplete */
816
+ .mention { color: var(--accent); font-weight: 600; }
817
+ .mention-list {
818
+ display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;
819
+ padding: 5px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-2);
820
+ }
821
+ .mention-pick {
822
+ padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
823
+ background: var(--bg); color: var(--ink); font-size: 11px; cursor: pointer;
824
+ }
825
+ .mention-pick:hover { border-color: var(--accent); color: var(--accent); }
826
+ /* "needs you" \u2014 the two reasons the stage cannot express */
827
+ .needsline {
828
+ display: flex; align-items: center; gap: 7px; margin: 8px 0; padding: 7px 9px;
829
+ border: 1px solid #e0a92c; border-radius: 9px; background: var(--bg-2);
830
+ font-size: 11.5px; font-weight: 600; color: var(--ink);
831
+ }
832
+ .needs-dot { width: 7px; height: 7px; border-radius: 50%; background: #e0a92c; flex: none; }
833
+
797
834
  /* ------------------------------------------------------------- conversation */
798
835
  .convo { margin-top: 8px; }
799
836
  .convo-loading { padding: 10px; text-align: center; font-size: 11.5px; color: var(--muted); }
@@ -2990,6 +3027,25 @@ var Loupe = (() => {
2990
3027
  localStorage.setItem(this.msgKey(threadId), JSON.stringify(all));
2991
3028
  return stored;
2992
3029
  }
3030
+ /** Offline: the people are whoever has already commented locally. */
3031
+ async listPeople(projectKey) {
3032
+ const prefix = `loupe:${projectKey}:`;
3033
+ const seen = /* @__PURE__ */ new Map();
3034
+ for (let i = 0; i < localStorage.length; i++) {
3035
+ const k = localStorage.key(i);
3036
+ if (!k || !k.startsWith(prefix)) continue;
3037
+ for (const c of this.parseList(k)) {
3038
+ if (c.author?.id && !seen.has(c.author.id)) seen.set(c.author.id, c.author);
3039
+ }
3040
+ }
3041
+ return [...seen.values()];
3042
+ }
3043
+ /** Offline mode has no server to notify anyone from. */
3044
+ async listNotifications() {
3045
+ return [];
3046
+ }
3047
+ async markNotificationsRead() {
3048
+ }
2993
3049
  async update(id, patch) {
2994
3050
  for (const k of this.commentKeys()) {
2995
3051
  const list = this.parseList(k);
@@ -3100,6 +3156,26 @@ var Loupe = (() => {
3100
3156
  if (!res.ok) throw new Error(`addMessage failed: ${res.status}`);
3101
3157
  return await res.json();
3102
3158
  }
3159
+ /** Everyone who has taken part in this project, for mention resolution. */
3160
+ async listPeople(projectKey) {
3161
+ const q = new URLSearchParams({ projectKey });
3162
+ const res = await fetch(`${this.base}/v1/people?${q}`, this.opts({ headers: this.headers() }));
3163
+ if (!res.ok) throw new Error(`listPeople failed: ${res.status}`);
3164
+ return await res.json();
3165
+ }
3166
+ async listNotifications(projectKey, recipient) {
3167
+ const q = new URLSearchParams({ projectKey, recipient });
3168
+ const res = await fetch(`${this.base}/v1/notifications?${q}`, this.opts({ headers: this.headers() }));
3169
+ if (!res.ok) throw new Error(`listNotifications failed: ${res.status}`);
3170
+ return (await res.json()).notifications;
3171
+ }
3172
+ async markNotificationsRead(projectKey, recipient, id) {
3173
+ await fetch(`${this.base}/v1/notifications/read`, this.opts({
3174
+ method: "POST",
3175
+ headers: this.headers(),
3176
+ body: JSON.stringify({ projectKey, recipient, id })
3177
+ }));
3178
+ }
3103
3179
  async update(id, patch) {
3104
3180
  const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3105
3181
  method: "PATCH",
@@ -3384,6 +3460,111 @@ var Loupe = (() => {
3384
3460
  return lines.join("\n");
3385
3461
  }
3386
3462
 
3463
+ // ../shared/dist/mentions.js
3464
+ var HANDLE = /^[A-Za-z0-9][A-Za-z0-9._-]*/;
3465
+ function excludedRanges(body) {
3466
+ const ranges = [];
3467
+ for (const m of body.matchAll(/```[\s\S]*?```/g))
3468
+ ranges.push([m.index, m.index + m[0].length]);
3469
+ const fenced = (i) => ranges.some(([a, b]) => i >= a && i < b);
3470
+ for (const m of body.matchAll(/`[^`\n]*`/g)) {
3471
+ if (!fenced(m.index))
3472
+ ranges.push([m.index, m.index + m[0].length]);
3473
+ }
3474
+ return ranges;
3475
+ }
3476
+ function parseMentions(body) {
3477
+ if (!body)
3478
+ return [];
3479
+ const excluded = excludedRanges(body);
3480
+ const inExcluded = (i) => excluded.some(([a, b]) => i >= a && i < b);
3481
+ const out = [];
3482
+ const seen = /* @__PURE__ */ new Set();
3483
+ for (let i = 0; i < body.length; i++) {
3484
+ if (body[i] !== "@")
3485
+ continue;
3486
+ if (inExcluded(i))
3487
+ continue;
3488
+ const prev = i > 0 ? body[i - 1] : "";
3489
+ if (prev && !/[\s([{<"'*_~]/.test(prev))
3490
+ continue;
3491
+ const match = HANDLE.exec(body.slice(i + 1));
3492
+ if (!match)
3493
+ continue;
3494
+ const raw = match[0];
3495
+ const handle = raw.replace(/[._-]+$/, "");
3496
+ if (!handle)
3497
+ continue;
3498
+ const key = handle.toLowerCase();
3499
+ if (seen.has(key))
3500
+ continue;
3501
+ seen.add(key);
3502
+ out.push({ handle, start: i, length: handle.length + 1 });
3503
+ }
3504
+ return out;
3505
+ }
3506
+ function mentionSegments(body, mentions) {
3507
+ if (!mentions.length)
3508
+ return [{ text: body, mention: false }];
3509
+ const sorted = [...mentions].sort((a, b) => a.start - b.start);
3510
+ const out = [];
3511
+ let at = 0;
3512
+ for (const m of sorted) {
3513
+ if (m.start > at)
3514
+ out.push({ text: body.slice(at, m.start), mention: false });
3515
+ out.push({ text: body.slice(m.start, m.start + m.length), mention: true });
3516
+ at = m.start + m.length;
3517
+ }
3518
+ if (at < body.length)
3519
+ out.push({ text: body.slice(at), mention: false });
3520
+ return out;
3521
+ }
3522
+ function mentionSuggestions(body, caret, candidates) {
3523
+ const before = body.slice(0, caret);
3524
+ const at = before.lastIndexOf("@");
3525
+ if (at < 0)
3526
+ return [];
3527
+ const typed = before.slice(at + 1);
3528
+ if (/\s/.test(typed))
3529
+ return [];
3530
+ const q = typed.toLowerCase();
3531
+ return candidates.filter((c) => !q || c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q)).slice(0, 6);
3532
+ }
3533
+
3534
+ // ../shared/dist/needs-you.js
3535
+ var NEEDS_YOU_LABELS = {
3536
+ review: "Waiting on your review",
3537
+ question: "The agent asked you something",
3538
+ failed: "The agent's run failed"
3539
+ };
3540
+ function looksLikeQuestion(body) {
3541
+ const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
3542
+ const last = lines[lines.length - 1];
3543
+ if (!last)
3544
+ return false;
3545
+ return /\?["')\]]*$/.test(last);
3546
+ }
3547
+ function needsYou(input) {
3548
+ const stage = normalizeStage2(input.status);
3549
+ if (stage === "resolved")
3550
+ return { needs: false };
3551
+ if (input.agentFailed)
3552
+ return { needs: true, reason: "failed", label: NEEDS_YOU_LABELS.failed };
3553
+ if (input.last?.fromAgent && looksLikeQuestion(input.last.body)) {
3554
+ return { needs: true, reason: "question", label: NEEDS_YOU_LABELS.question };
3555
+ }
3556
+ if (stage === "in_review")
3557
+ return { needs: true, reason: "review", label: NEEDS_YOU_LABELS.review };
3558
+ return { needs: false };
3559
+ }
3560
+ function normalizeStage2(status) {
3561
+ if (status === "open")
3562
+ return "queue";
3563
+ if (status === "done")
3564
+ return "resolved";
3565
+ return status;
3566
+ }
3567
+
3387
3568
  // ../shared/dist/index.js
3388
3569
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
3389
3570
  var STAGE_LABELS = {
@@ -3431,7 +3612,7 @@ var Loupe = (() => {
3431
3612
  }
3432
3613
 
3433
3614
  // src/app.ts
3434
- var SDK_VERSION = true ? "0.10.22" : "dev";
3615
+ var SDK_VERSION = true ? "0.10.23" : "dev";
3435
3616
  var ACCENTS = [
3436
3617
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3437
3618
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3620,6 +3801,8 @@ var Loupe = (() => {
3620
3801
  * rather than a `.tab-<id>` selector, so host-registered ids need no CSS.
3621
3802
  */
3622
3803
  this.viewEls = /* @__PURE__ */ new Map();
3804
+ /** In-app notifications: mentions of you, newest first. */
3805
+ this.notifications = [];
3623
3806
  // ---- inspector ------------------------------------------------------------
3624
3807
  // Pointer Events, not mouse events: a touch drag on a phone never fires
3625
3808
  // mousemove/mouseup, so Region/Record selection was impossible on touch devices.
@@ -3847,6 +4030,8 @@ var Loupe = (() => {
3847
4030
  /** Optimistic replies the store rejected — offered for retry rather than lost. */
3848
4031
  this.msgFailed = /* @__PURE__ */ new Set();
3849
4032
  this.msgErr = /* @__PURE__ */ new Map();
4033
+ /** Everyone who has taken part, for mention autocomplete. Fetched once. */
4034
+ this.people = /* @__PURE__ */ new Map();
3850
4035
  this.cfg = cfg;
3851
4036
  this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac, cfg.headers, cfg.credentials) : new LocalStorageAdapter();
3852
4037
  }
@@ -3864,6 +4049,7 @@ var Loupe = (() => {
3864
4049
  this.renderPins();
3865
4050
  this.renderList();
3866
4051
  this.renderHome();
4052
+ void this.loadNotifications();
3867
4053
  if (this.scope === "all") void this.loadAllComments();
3868
4054
  this.observe();
3869
4055
  this.watchNavigation();
@@ -4239,7 +4425,7 @@ var Loupe = (() => {
4239
4425
  */
4240
4426
  buildHomePanel() {
4241
4427
  const title = this.cfg.label ?? "Loupe";
4242
- this.homeEl.innerHTML = `<div class="hint-slot" id="loupe-hhint"></div><div class="hstat" id="loupe-hstats"></div><div class="hscope"><button class="hscope-b" data-scope="page">This page <b class="hscope-n"></b></button><button class="hscope-b" data-scope="all">All <b class="hscope-n"></b></button><button class="hrefresh" title="Refresh" aria-label="Refresh">\u27F3</button></div><button class="hpin" data-role="home-pin">\u271B Pin feedback on this page</button><div class="projbar"><span class="proj-label">Project</span><button class="proj-chip" data-role="proj-open" aria-haspopup="dialog" aria-expanded="false"><span class="proj-repo"></span><span class="proj-caret">\u25BE</span></button><div class="proj-pop" id="loupe-proj" role="dialog" aria-label="Project settings"></div></div><div class="hlabel">Recent</div><div class="hfeed" id="loupe-hfeed"></div><div class="hfoot">${escapeHtml(title)} \xB7 <span class="hver">v${escapeHtml(SDK_VERSION)}</span></div>`;
4428
+ this.homeEl.innerHTML = `<div class="hint-slot" id="loupe-hhint"></div><div class="hstat" id="loupe-hstats"></div><div class="hscope"><button class="hscope-b" data-scope="page">This page <b class="hscope-n"></b></button><button class="hscope-b" data-scope="all">All <b class="hscope-n"></b></button><button class="hrefresh" title="Refresh" aria-label="Refresh">\u27F3</button></div><button class="hpin" data-role="home-pin">\u271B Pin feedback on this page</button><div class="projbar"><span class="proj-label">Project</span><button class="proj-chip" data-role="proj-open" aria-haspopup="dialog" aria-expanded="false"><span class="proj-repo"></span><span class="proj-caret">\u25BE</span></button><div class="proj-pop" id="loupe-proj" role="dialog" aria-label="Project settings"></div></div><div class="hnotif" id="loupe-hnotif"></div><div class="hlabel">Recent</div><div class="hfeed" id="loupe-hfeed"></div><div class="hfoot">${escapeHtml(title)} \xB7 <span class="hver">v${escapeHtml(SDK_VERSION)}</span></div>`;
4243
4429
  this.homeEl.querySelector('[data-role="proj-open"]').onclick = (e) => {
4244
4430
  e.stopPropagation();
4245
4431
  this.setProjectOpen(!this.projOpen);
@@ -4285,7 +4471,10 @@ var Loupe = (() => {
4285
4471
  const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
4286
4472
  return [
4287
4473
  { key: "open", label: "Open", n: list.filter((c) => normalizeStatus(c.status) !== "resolved").length },
4288
- { key: "needs_you", label: "Needs you", n: list.filter((c) => normalizeStatus(c.status) === "in_review").length },
4474
+ // The shared predicate, so the panel and the dashboard cannot disagree about
4475
+ // what "needs you" means. Only the stage is available here — the reasons that
4476
+ // depend on the last message are computed where the messages are loaded.
4477
+ { key: "needs_you", label: "Needs you", n: list.filter((c) => needsYou({ status: c.status }).needs).length },
4289
4478
  { key: "resolved", label: "Resolved", n: list.filter((c) => normalizeStatus(c.status) === "resolved").length },
4290
4479
  {
4291
4480
  key: "stale",
@@ -4322,6 +4511,7 @@ var Loupe = (() => {
4322
4511
  b.querySelector(".hscope-n").textContent = counts[scope];
4323
4512
  });
4324
4513
  this.renderProject();
4514
+ this.renderNotifications();
4325
4515
  const feed = this.homeEl.querySelector("#loupe-hfeed");
4326
4516
  if (!feed) return;
4327
4517
  const recent = [...this.visibleComments].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 8);
@@ -4341,6 +4531,50 @@ var Loupe = (() => {
4341
4531
  };
4342
4532
  });
4343
4533
  }
4534
+ async loadNotifications() {
4535
+ try {
4536
+ const list = await this.store.listNotifications(this.cfg.projectKey, this.cfg.user.id);
4537
+ this.notifications = Array.isArray(list) ? list : [];
4538
+ } catch {
4539
+ this.notifications = [];
4540
+ }
4541
+ this.renderNotifications();
4542
+ }
4543
+ /**
4544
+ * The mentions block. Rendered only when there are unread ones — a permanent empty
4545
+ * "0 unread" is noise, and the whole point is that it appears when it matters.
4546
+ */
4547
+ renderNotifications() {
4548
+ const box = this.homeEl?.querySelector("#loupe-hnotif");
4549
+ if (!box) return;
4550
+ const unread = this.notifications.filter((n) => !n.readAt);
4551
+ if (!unread.length) {
4552
+ box.innerHTML = "";
4553
+ box.style.display = "none";
4554
+ return;
4555
+ }
4556
+ box.style.display = "";
4557
+ box.innerHTML = `<div class="nf-head"><span class="nf-dot"></span><b>${unread.length}</b> mention${unread.length === 1 ? "" : "s"} waiting</div>` + unread.slice(0, 3).map((n) => `<button class="nf-i" data-thread="${escapeAttr(n.threadId)}"><span class="nf-b">${escapeHtml(n.body)}</span><span class="nf-w">${escapeHtml(fmtAgo(n.createdAt))}</span></button>`).join("") + `<button class="nf-read">Mark as read</button>`;
4558
+ box.querySelectorAll(".nf-i").forEach((b) => {
4559
+ b.onclick = () => {
4560
+ const id = b.dataset.thread;
4561
+ this.scope = "all";
4562
+ if (!this.allComments.length) void this.loadAllComments();
4563
+ this.statFilter = "";
4564
+ this.setTab("comments");
4565
+ this.expanded.add(id);
4566
+ this.renderList();
4567
+ this.flash(id);
4568
+ };
4569
+ });
4570
+ box.querySelector(".nf-read").onclick = async () => {
4571
+ try {
4572
+ await this.store.markNotificationsRead(this.cfg.projectKey, this.cfg.user.id);
4573
+ } catch {
4574
+ }
4575
+ void this.loadNotifications();
4576
+ };
4577
+ }
4344
4578
  // ---- project manager (#60) ------------------------------------------------
4345
4579
  /** The repo new comments are filed against: the panel's choice, else the config. */
4346
4580
  effectiveRepo() {
@@ -5444,7 +5678,7 @@ var Loupe = (() => {
5444
5678
  let items = this.visibleComments.filter((c) => {
5445
5679
  const stage = normalizeStatus(c.status);
5446
5680
  if (this.statFilter === "open" && stage === "resolved") return false;
5447
- if (this.statFilter === "needs_you" && stage !== "in_review") return false;
5681
+ if (this.statFilter === "needs_you" && !needsYou({ status: c.status }).needs) return false;
5448
5682
  if (this.statFilter === "resolved" && stage !== "resolved") return false;
5449
5683
  if (this.statFilter === "stale" && (stage === "resolved" || !(Date.parse(c.createdAt) < weekAgo))) return false;
5450
5684
  if (this.repoFilter && c.repo !== this.repoFilter) return false;
@@ -5546,6 +5780,18 @@ var Loupe = (() => {
5546
5780
  const summary = c.title || (c.body.split("\n")[0] ?? "").slice(0, 140) || "(no description)";
5547
5781
  item.appendChild(el("div", "summary", summary));
5548
5782
  const detail = el("div", "detail");
5783
+ const repliesForState = this.messages.get(c.id) ?? [];
5784
+ const lastMsg = repliesForState.length ? repliesForState[repliesForState.length - 1] : void 0;
5785
+ const attention = needsYou({
5786
+ status: c.status,
5787
+ last: lastMsg ? { fromAgent: lastMsg.author.type === "agent", body: lastMsg.body } : null,
5788
+ agentFailed: this.msgFailed.has(lastMsg?.id ?? "")
5789
+ });
5790
+ if (c.status !== "resolved" && attention.needs && attention.reason !== "review") {
5791
+ const line = el("div", "needsline");
5792
+ line.append(el("span", "needs-dot"), el("span", "", attention.label ?? "Needs you"));
5793
+ detail.appendChild(line);
5794
+ }
5549
5795
  if (c.status === "in_review") detail.appendChild(this.reviewBanner(c));
5550
5796
  if (c.proposal) detail.appendChild(this.proposalView(c));
5551
5797
  if (c.title || c.body.includes("\n")) detail.appendChild(el("div", "body", c.body));
@@ -6106,7 +6352,12 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6106
6352
  el("span", "msg-when", fmtAgo(m.createdAt))
6107
6353
  );
6108
6354
  if (fromAgent) head.appendChild(el("span", "msg-tag", "agent"));
6109
- row.append(head, el("div", "msg-body", m.body));
6355
+ const body = el("div", "msg-body");
6356
+ for (const seg of mentionSegments(m.body, parseMentions(m.body))) {
6357
+ if (seg.mention) body.appendChild(el("span", "mention", seg.text));
6358
+ else body.appendChild(document.createTextNode(seg.text));
6359
+ }
6360
+ row.append(head, body);
6110
6361
  if (this.msgPending.has(m.id)) row.appendChild(el("div", "msg-state", "Sending\u2026"));
6111
6362
  if (this.msgFailed.has(m.id)) {
6112
6363
  const failed = el("div", "msg-state failed");
@@ -6143,7 +6394,40 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6143
6394
  void this.sendReply(c, input.value);
6144
6395
  };
6145
6396
  foot.append(el("span", "reply-hint", "@ to mention"), send);
6146
- reply.append(input, foot);
6397
+ const suggest = el("div", "mention-list");
6398
+ suggest.style.display = "none";
6399
+ const refreshSuggestions = () => {
6400
+ const matches = mentionSuggestions(input.value, input.selectionStart ?? input.value.length, this.people.get(this.cfg.projectKey) ?? []);
6401
+ suggest.textContent = "";
6402
+ if (!matches.length) {
6403
+ suggest.style.display = "none";
6404
+ return;
6405
+ }
6406
+ suggest.style.display = "";
6407
+ for (const person of matches) {
6408
+ const b = el("button", "mention-pick", person.name);
6409
+ b.onclick = (e) => {
6410
+ e.stopPropagation();
6411
+ const before = input.value.slice(0, input.selectionStart ?? input.value.length);
6412
+ const at = before.lastIndexOf("@");
6413
+ const after = input.value.slice(input.selectionStart ?? input.value.length);
6414
+ input.value = `${before.slice(0, at)}@${person.name.replace(/\s+/g, "")} ${after}`;
6415
+ this.msgDrafts.set(c.id, input.value);
6416
+ suggest.style.display = "none";
6417
+ input.focus();
6418
+ };
6419
+ suggest.appendChild(b);
6420
+ }
6421
+ };
6422
+ input.oninput = () => {
6423
+ this.msgDrafts.set(c.id, input.value);
6424
+ refreshSuggestions();
6425
+ };
6426
+ input.onkeyup = () => refreshSuggestions();
6427
+ input.onblur = () => setTimeout(() => {
6428
+ suggest.style.display = "none";
6429
+ }, 150);
6430
+ reply.append(input, suggest, foot);
6147
6431
  wrap.appendChild(reply);
6148
6432
  const timeline = threadTimeline(c, asRows);
6149
6433
  const tl = el("div", "tl");
@@ -6185,9 +6469,18 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6185
6469
  wrap.appendChild(copyRow);
6186
6470
  return wrap;
6187
6471
  }
6472
+ async loadPeople() {
6473
+ if (this.people.has(this.cfg.projectKey)) return;
6474
+ try {
6475
+ this.people.set(this.cfg.projectKey, await this.store.listPeople(this.cfg.projectKey));
6476
+ } catch {
6477
+ this.people.set(this.cfg.projectKey, []);
6478
+ }
6479
+ }
6188
6480
  /** Fetch replies once, when a card is first expanded. */
6189
6481
  async loadMessages(c) {
6190
6482
  if (this.messages.has(c.id)) return;
6483
+ void this.loadPeople();
6191
6484
  try {
6192
6485
  this.messages.set(c.id, await this.store.listMessages(c.id));
6193
6486
  } catch {