@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.
package/dist/index.js CHANGED
@@ -760,6 +760,43 @@ var STYLES = (
760
760
  .voice:disabled { opacity: .45; cursor: not-allowed; }
761
761
  .voice.on { border-color: var(--pin); color: var(--pin); background: var(--bg-3); }
762
762
 
763
+ /* in-app mentions */
764
+ .hnotif { margin-top: 10px; }
765
+ .nf-head { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; font-size: 11.5px; color: var(--ink); }
766
+ .nf-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }
767
+ .nf-i {
768
+ display: flex; align-items: baseline; gap: 7px; width: 100%; text-align: left;
769
+ padding: 6px 8px; margin-bottom: 4px; border: 1px solid var(--accent); border-radius: 8px;
770
+ background: var(--bg-2); color: var(--ink); font-size: 11px; cursor: pointer;
771
+ }
772
+ .nf-i:hover { background: var(--bg-3); }
773
+ .nf-b { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
774
+ .nf-w { flex: none; color: var(--muted); font-size: 10.5px; }
775
+ .nf-read {
776
+ margin-top: 2px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 7px;
777
+ background: var(--bg); color: var(--muted); font-size: 10.5px; cursor: pointer;
778
+ }
779
+ .nf-read:hover { border-color: var(--accent); color: var(--ink); }
780
+
781
+ /* mention highlighting + autocomplete */
782
+ .mention { color: var(--accent); font-weight: 600; }
783
+ .mention-list {
784
+ display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;
785
+ padding: 5px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg-2);
786
+ }
787
+ .mention-pick {
788
+ padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
789
+ background: var(--bg); color: var(--ink); font-size: 11px; cursor: pointer;
790
+ }
791
+ .mention-pick:hover { border-color: var(--accent); color: var(--accent); }
792
+ /* "needs you" \u2014 the two reasons the stage cannot express */
793
+ .needsline {
794
+ display: flex; align-items: center; gap: 7px; margin: 8px 0; padding: 7px 9px;
795
+ border: 1px solid #e0a92c; border-radius: 9px; background: var(--bg-2);
796
+ font-size: 11.5px; font-weight: 600; color: var(--ink);
797
+ }
798
+ .needs-dot { width: 7px; height: 7px; border-radius: 50%; background: #e0a92c; flex: none; }
799
+
763
800
  /* ------------------------------------------------------------- conversation */
764
801
  .convo { margin-top: 8px; }
765
802
  .convo-loading { padding: 10px; text-align: center; font-size: 11.5px; color: var(--muted); }
@@ -2956,6 +2993,25 @@ var LocalStorageAdapter = class {
2956
2993
  localStorage.setItem(this.msgKey(threadId), JSON.stringify(all));
2957
2994
  return stored;
2958
2995
  }
2996
+ /** Offline: the people are whoever has already commented locally. */
2997
+ async listPeople(projectKey) {
2998
+ const prefix = `loupe:${projectKey}:`;
2999
+ const seen = /* @__PURE__ */ new Map();
3000
+ for (let i = 0; i < localStorage.length; i++) {
3001
+ const k = localStorage.key(i);
3002
+ if (!k || !k.startsWith(prefix)) continue;
3003
+ for (const c of this.parseList(k)) {
3004
+ if (c.author?.id && !seen.has(c.author.id)) seen.set(c.author.id, c.author);
3005
+ }
3006
+ }
3007
+ return [...seen.values()];
3008
+ }
3009
+ /** Offline mode has no server to notify anyone from. */
3010
+ async listNotifications() {
3011
+ return [];
3012
+ }
3013
+ async markNotificationsRead() {
3014
+ }
2959
3015
  async update(id, patch) {
2960
3016
  for (const k of this.commentKeys()) {
2961
3017
  const list = this.parseList(k);
@@ -3066,6 +3122,26 @@ var HttpAdapter = class {
3066
3122
  if (!res.ok) throw new Error(`addMessage failed: ${res.status}`);
3067
3123
  return await res.json();
3068
3124
  }
3125
+ /** Everyone who has taken part in this project, for mention resolution. */
3126
+ async listPeople(projectKey) {
3127
+ const q = new URLSearchParams({ projectKey });
3128
+ const res = await fetch(`${this.base}/v1/people?${q}`, this.opts({ headers: this.headers() }));
3129
+ if (!res.ok) throw new Error(`listPeople failed: ${res.status}`);
3130
+ return await res.json();
3131
+ }
3132
+ async listNotifications(projectKey, recipient) {
3133
+ const q = new URLSearchParams({ projectKey, recipient });
3134
+ const res = await fetch(`${this.base}/v1/notifications?${q}`, this.opts({ headers: this.headers() }));
3135
+ if (!res.ok) throw new Error(`listNotifications failed: ${res.status}`);
3136
+ return (await res.json()).notifications;
3137
+ }
3138
+ async markNotificationsRead(projectKey, recipient, id) {
3139
+ await fetch(`${this.base}/v1/notifications/read`, this.opts({
3140
+ method: "POST",
3141
+ headers: this.headers(),
3142
+ body: JSON.stringify({ projectKey, recipient, id })
3143
+ }));
3144
+ }
3069
3145
  async update(id, patch) {
3070
3146
  const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3071
3147
  method: "PATCH",
@@ -3350,6 +3426,111 @@ function threadAsText(input, messages = []) {
3350
3426
  return lines.join("\n");
3351
3427
  }
3352
3428
 
3429
+ // ../shared/dist/mentions.js
3430
+ var HANDLE = /^[A-Za-z0-9][A-Za-z0-9._-]*/;
3431
+ function excludedRanges(body) {
3432
+ const ranges = [];
3433
+ for (const m of body.matchAll(/```[\s\S]*?```/g))
3434
+ ranges.push([m.index, m.index + m[0].length]);
3435
+ const fenced = (i) => ranges.some(([a, b]) => i >= a && i < b);
3436
+ for (const m of body.matchAll(/`[^`\n]*`/g)) {
3437
+ if (!fenced(m.index))
3438
+ ranges.push([m.index, m.index + m[0].length]);
3439
+ }
3440
+ return ranges;
3441
+ }
3442
+ function parseMentions(body) {
3443
+ if (!body)
3444
+ return [];
3445
+ const excluded = excludedRanges(body);
3446
+ const inExcluded = (i) => excluded.some(([a, b]) => i >= a && i < b);
3447
+ const out = [];
3448
+ const seen = /* @__PURE__ */ new Set();
3449
+ for (let i = 0; i < body.length; i++) {
3450
+ if (body[i] !== "@")
3451
+ continue;
3452
+ if (inExcluded(i))
3453
+ continue;
3454
+ const prev = i > 0 ? body[i - 1] : "";
3455
+ if (prev && !/[\s([{<"'*_~]/.test(prev))
3456
+ continue;
3457
+ const match = HANDLE.exec(body.slice(i + 1));
3458
+ if (!match)
3459
+ continue;
3460
+ const raw = match[0];
3461
+ const handle = raw.replace(/[._-]+$/, "");
3462
+ if (!handle)
3463
+ continue;
3464
+ const key = handle.toLowerCase();
3465
+ if (seen.has(key))
3466
+ continue;
3467
+ seen.add(key);
3468
+ out.push({ handle, start: i, length: handle.length + 1 });
3469
+ }
3470
+ return out;
3471
+ }
3472
+ function mentionSegments(body, mentions) {
3473
+ if (!mentions.length)
3474
+ return [{ text: body, mention: false }];
3475
+ const sorted = [...mentions].sort((a, b) => a.start - b.start);
3476
+ const out = [];
3477
+ let at = 0;
3478
+ for (const m of sorted) {
3479
+ if (m.start > at)
3480
+ out.push({ text: body.slice(at, m.start), mention: false });
3481
+ out.push({ text: body.slice(m.start, m.start + m.length), mention: true });
3482
+ at = m.start + m.length;
3483
+ }
3484
+ if (at < body.length)
3485
+ out.push({ text: body.slice(at), mention: false });
3486
+ return out;
3487
+ }
3488
+ function mentionSuggestions(body, caret, candidates) {
3489
+ const before = body.slice(0, caret);
3490
+ const at = before.lastIndexOf("@");
3491
+ if (at < 0)
3492
+ return [];
3493
+ const typed = before.slice(at + 1);
3494
+ if (/\s/.test(typed))
3495
+ return [];
3496
+ const q = typed.toLowerCase();
3497
+ return candidates.filter((c) => !q || c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q)).slice(0, 6);
3498
+ }
3499
+
3500
+ // ../shared/dist/needs-you.js
3501
+ var NEEDS_YOU_LABELS = {
3502
+ review: "Waiting on your review",
3503
+ question: "The agent asked you something",
3504
+ failed: "The agent's run failed"
3505
+ };
3506
+ function looksLikeQuestion(body) {
3507
+ const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
3508
+ const last = lines[lines.length - 1];
3509
+ if (!last)
3510
+ return false;
3511
+ return /\?["')\]]*$/.test(last);
3512
+ }
3513
+ function needsYou(input) {
3514
+ const stage = normalizeStage2(input.status);
3515
+ if (stage === "resolved")
3516
+ return { needs: false };
3517
+ if (input.agentFailed)
3518
+ return { needs: true, reason: "failed", label: NEEDS_YOU_LABELS.failed };
3519
+ if (input.last?.fromAgent && looksLikeQuestion(input.last.body)) {
3520
+ return { needs: true, reason: "question", label: NEEDS_YOU_LABELS.question };
3521
+ }
3522
+ if (stage === "in_review")
3523
+ return { needs: true, reason: "review", label: NEEDS_YOU_LABELS.review };
3524
+ return { needs: false };
3525
+ }
3526
+ function normalizeStage2(status) {
3527
+ if (status === "open")
3528
+ return "queue";
3529
+ if (status === "done")
3530
+ return "resolved";
3531
+ return status;
3532
+ }
3533
+
3353
3534
  // ../shared/dist/index.js
3354
3535
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
3355
3536
  var STAGE_LABELS = {
@@ -3397,7 +3578,7 @@ function normalizePriority(value) {
3397
3578
  }
3398
3579
 
3399
3580
  // src/app.ts
3400
- var SDK_VERSION = true ? "0.10.22" : "dev";
3581
+ var SDK_VERSION = true ? "0.10.23" : "dev";
3401
3582
  var ACCENTS = [
3402
3583
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3403
3584
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3586,6 +3767,8 @@ var LoupeApp = class {
3586
3767
  * rather than a `.tab-<id>` selector, so host-registered ids need no CSS.
3587
3768
  */
3588
3769
  this.viewEls = /* @__PURE__ */ new Map();
3770
+ /** In-app notifications: mentions of you, newest first. */
3771
+ this.notifications = [];
3589
3772
  // ---- inspector ------------------------------------------------------------
3590
3773
  // Pointer Events, not mouse events: a touch drag on a phone never fires
3591
3774
  // mousemove/mouseup, so Region/Record selection was impossible on touch devices.
@@ -3813,6 +3996,8 @@ var LoupeApp = class {
3813
3996
  /** Optimistic replies the store rejected — offered for retry rather than lost. */
3814
3997
  this.msgFailed = /* @__PURE__ */ new Set();
3815
3998
  this.msgErr = /* @__PURE__ */ new Map();
3999
+ /** Everyone who has taken part, for mention autocomplete. Fetched once. */
4000
+ this.people = /* @__PURE__ */ new Map();
3816
4001
  this.cfg = cfg;
3817
4002
  this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac, cfg.headers, cfg.credentials) : new LocalStorageAdapter();
3818
4003
  }
@@ -3830,6 +4015,7 @@ var LoupeApp = class {
3830
4015
  this.renderPins();
3831
4016
  this.renderList();
3832
4017
  this.renderHome();
4018
+ void this.loadNotifications();
3833
4019
  if (this.scope === "all") void this.loadAllComments();
3834
4020
  this.observe();
3835
4021
  this.watchNavigation();
@@ -4205,7 +4391,7 @@ var LoupeApp = class {
4205
4391
  */
4206
4392
  buildHomePanel() {
4207
4393
  const title = this.cfg.label ?? "Loupe";
4208
- 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>`;
4394
+ 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>`;
4209
4395
  this.homeEl.querySelector('[data-role="proj-open"]').onclick = (e) => {
4210
4396
  e.stopPropagation();
4211
4397
  this.setProjectOpen(!this.projOpen);
@@ -4251,7 +4437,10 @@ var LoupeApp = class {
4251
4437
  const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1e3;
4252
4438
  return [
4253
4439
  { key: "open", label: "Open", n: list.filter((c) => normalizeStatus(c.status) !== "resolved").length },
4254
- { key: "needs_you", label: "Needs you", n: list.filter((c) => normalizeStatus(c.status) === "in_review").length },
4440
+ // The shared predicate, so the panel and the dashboard cannot disagree about
4441
+ // what "needs you" means. Only the stage is available here — the reasons that
4442
+ // depend on the last message are computed where the messages are loaded.
4443
+ { key: "needs_you", label: "Needs you", n: list.filter((c) => needsYou({ status: c.status }).needs).length },
4255
4444
  { key: "resolved", label: "Resolved", n: list.filter((c) => normalizeStatus(c.status) === "resolved").length },
4256
4445
  {
4257
4446
  key: "stale",
@@ -4288,6 +4477,7 @@ var LoupeApp = class {
4288
4477
  b.querySelector(".hscope-n").textContent = counts[scope];
4289
4478
  });
4290
4479
  this.renderProject();
4480
+ this.renderNotifications();
4291
4481
  const feed = this.homeEl.querySelector("#loupe-hfeed");
4292
4482
  if (!feed) return;
4293
4483
  const recent = [...this.visibleComments].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 8);
@@ -4307,6 +4497,50 @@ var LoupeApp = class {
4307
4497
  };
4308
4498
  });
4309
4499
  }
4500
+ async loadNotifications() {
4501
+ try {
4502
+ const list = await this.store.listNotifications(this.cfg.projectKey, this.cfg.user.id);
4503
+ this.notifications = Array.isArray(list) ? list : [];
4504
+ } catch {
4505
+ this.notifications = [];
4506
+ }
4507
+ this.renderNotifications();
4508
+ }
4509
+ /**
4510
+ * The mentions block. Rendered only when there are unread ones — a permanent empty
4511
+ * "0 unread" is noise, and the whole point is that it appears when it matters.
4512
+ */
4513
+ renderNotifications() {
4514
+ const box = this.homeEl?.querySelector("#loupe-hnotif");
4515
+ if (!box) return;
4516
+ const unread = this.notifications.filter((n) => !n.readAt);
4517
+ if (!unread.length) {
4518
+ box.innerHTML = "";
4519
+ box.style.display = "none";
4520
+ return;
4521
+ }
4522
+ box.style.display = "";
4523
+ 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>`;
4524
+ box.querySelectorAll(".nf-i").forEach((b) => {
4525
+ b.onclick = () => {
4526
+ const id = b.dataset.thread;
4527
+ this.scope = "all";
4528
+ if (!this.allComments.length) void this.loadAllComments();
4529
+ this.statFilter = "";
4530
+ this.setTab("comments");
4531
+ this.expanded.add(id);
4532
+ this.renderList();
4533
+ this.flash(id);
4534
+ };
4535
+ });
4536
+ box.querySelector(".nf-read").onclick = async () => {
4537
+ try {
4538
+ await this.store.markNotificationsRead(this.cfg.projectKey, this.cfg.user.id);
4539
+ } catch {
4540
+ }
4541
+ void this.loadNotifications();
4542
+ };
4543
+ }
4310
4544
  // ---- project manager (#60) ------------------------------------------------
4311
4545
  /** The repo new comments are filed against: the panel's choice, else the config. */
4312
4546
  effectiveRepo() {
@@ -5410,7 +5644,7 @@ var LoupeApp = class {
5410
5644
  let items = this.visibleComments.filter((c) => {
5411
5645
  const stage = normalizeStatus(c.status);
5412
5646
  if (this.statFilter === "open" && stage === "resolved") return false;
5413
- if (this.statFilter === "needs_you" && stage !== "in_review") return false;
5647
+ if (this.statFilter === "needs_you" && !needsYou({ status: c.status }).needs) return false;
5414
5648
  if (this.statFilter === "resolved" && stage !== "resolved") return false;
5415
5649
  if (this.statFilter === "stale" && (stage === "resolved" || !(Date.parse(c.createdAt) < weekAgo))) return false;
5416
5650
  if (this.repoFilter && c.repo !== this.repoFilter) return false;
@@ -5512,6 +5746,18 @@ var LoupeApp = class {
5512
5746
  const summary = c.title || (c.body.split("\n")[0] ?? "").slice(0, 140) || "(no description)";
5513
5747
  item.appendChild(el("div", "summary", summary));
5514
5748
  const detail = el("div", "detail");
5749
+ const repliesForState = this.messages.get(c.id) ?? [];
5750
+ const lastMsg = repliesForState.length ? repliesForState[repliesForState.length - 1] : void 0;
5751
+ const attention = needsYou({
5752
+ status: c.status,
5753
+ last: lastMsg ? { fromAgent: lastMsg.author.type === "agent", body: lastMsg.body } : null,
5754
+ agentFailed: this.msgFailed.has(lastMsg?.id ?? "")
5755
+ });
5756
+ if (c.status !== "resolved" && attention.needs && attention.reason !== "review") {
5757
+ const line = el("div", "needsline");
5758
+ line.append(el("span", "needs-dot"), el("span", "", attention.label ?? "Needs you"));
5759
+ detail.appendChild(line);
5760
+ }
5515
5761
  if (c.status === "in_review") detail.appendChild(this.reviewBanner(c));
5516
5762
  if (c.proposal) detail.appendChild(this.proposalView(c));
5517
5763
  if (c.title || c.body.includes("\n")) detail.appendChild(el("div", "body", c.body));
@@ -6072,7 +6318,12 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6072
6318
  el("span", "msg-when", fmtAgo(m.createdAt))
6073
6319
  );
6074
6320
  if (fromAgent) head.appendChild(el("span", "msg-tag", "agent"));
6075
- row.append(head, el("div", "msg-body", m.body));
6321
+ const body = el("div", "msg-body");
6322
+ for (const seg of mentionSegments(m.body, parseMentions(m.body))) {
6323
+ if (seg.mention) body.appendChild(el("span", "mention", seg.text));
6324
+ else body.appendChild(document.createTextNode(seg.text));
6325
+ }
6326
+ row.append(head, body);
6076
6327
  if (this.msgPending.has(m.id)) row.appendChild(el("div", "msg-state", "Sending\u2026"));
6077
6328
  if (this.msgFailed.has(m.id)) {
6078
6329
  const failed = el("div", "msg-state failed");
@@ -6109,7 +6360,40 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6109
6360
  void this.sendReply(c, input.value);
6110
6361
  };
6111
6362
  foot.append(el("span", "reply-hint", "@ to mention"), send);
6112
- reply.append(input, foot);
6363
+ const suggest = el("div", "mention-list");
6364
+ suggest.style.display = "none";
6365
+ const refreshSuggestions = () => {
6366
+ const matches = mentionSuggestions(input.value, input.selectionStart ?? input.value.length, this.people.get(this.cfg.projectKey) ?? []);
6367
+ suggest.textContent = "";
6368
+ if (!matches.length) {
6369
+ suggest.style.display = "none";
6370
+ return;
6371
+ }
6372
+ suggest.style.display = "";
6373
+ for (const person of matches) {
6374
+ const b = el("button", "mention-pick", person.name);
6375
+ b.onclick = (e) => {
6376
+ e.stopPropagation();
6377
+ const before = input.value.slice(0, input.selectionStart ?? input.value.length);
6378
+ const at = before.lastIndexOf("@");
6379
+ const after = input.value.slice(input.selectionStart ?? input.value.length);
6380
+ input.value = `${before.slice(0, at)}@${person.name.replace(/\s+/g, "")} ${after}`;
6381
+ this.msgDrafts.set(c.id, input.value);
6382
+ suggest.style.display = "none";
6383
+ input.focus();
6384
+ };
6385
+ suggest.appendChild(b);
6386
+ }
6387
+ };
6388
+ input.oninput = () => {
6389
+ this.msgDrafts.set(c.id, input.value);
6390
+ refreshSuggestions();
6391
+ };
6392
+ input.onkeyup = () => refreshSuggestions();
6393
+ input.onblur = () => setTimeout(() => {
6394
+ suggest.style.display = "none";
6395
+ }, 150);
6396
+ reply.append(input, suggest, foot);
6113
6397
  wrap.appendChild(reply);
6114
6398
  const timeline = threadTimeline(c, asRows);
6115
6399
  const tl = el("div", "tl");
@@ -6151,9 +6435,18 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6151
6435
  wrap.appendChild(copyRow);
6152
6436
  return wrap;
6153
6437
  }
6438
+ async loadPeople() {
6439
+ if (this.people.has(this.cfg.projectKey)) return;
6440
+ try {
6441
+ this.people.set(this.cfg.projectKey, await this.store.listPeople(this.cfg.projectKey));
6442
+ } catch {
6443
+ this.people.set(this.cfg.projectKey, []);
6444
+ }
6445
+ }
6154
6446
  /** Fetch replies once, when a card is first expanded. */
6155
6447
  async loadMessages(c) {
6156
6448
  if (this.messages.has(c.id)) return;
6449
+ void this.loadPeople();
6157
6450
  try {
6158
6451
  this.messages.set(c.id, await this.store.listMessages(c.id));
6159
6452
  } catch {