@loupekit/sdk 0.10.21 → 0.10.22-next.68

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,68 @@ 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
+ /* ------------------------------------------------------------- conversation */
764
+ .convo { margin-top: 8px; }
765
+ .convo-loading { padding: 10px; text-align: center; font-size: 11.5px; color: var(--muted); }
766
+ .msgs { display: flex; flex-direction: column; gap: 6px; }
767
+ .msg { padding: 7px 8px; border-radius: 9px; background: var(--bg-2); border: 1px solid transparent; }
768
+ /* An agent's reply is called out with a left accent bar and a lighter container \u2014
769
+ it should be obvious at a glance who you are talking to. */
770
+ .msg.agent { border-left: 3px solid var(--accent); background: var(--bg-3); }
771
+ .msg.pending { opacity: .65; }
772
+ .msg.failed { border-color: var(--pin); }
773
+ .msg-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
774
+ .msg-av {
775
+ width: 20px; height: 20px; border-radius: 50%; flex: none;
776
+ display: grid; place-items: center; background: var(--bg-3); color: var(--muted);
777
+ font-size: 10px; font-weight: 700;
778
+ }
779
+ .msg-av.agent { background: var(--accent); color: #fff; }
780
+ .msg-name { font-size: 11.5px; color: var(--ink); }
781
+ .msg-when { flex: 1; font-size: 10.5px; color: var(--muted); }
782
+ .msg-tag {
783
+ padding: 0 5px; border-radius: 999px; background: var(--accent); color: #fff;
784
+ font-size: 9px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
785
+ }
786
+ .msg-body { font-size: 11.5px; line-height: 1.45; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
787
+ .msg-state { margin-top: 4px; font-size: 10.5px; color: var(--muted); }
788
+ .msg-state.failed { color: var(--pin); }
789
+ .msg-retry {
790
+ margin-left: 6px; padding: 1px 7px; border: 1px solid var(--pin); border-radius: 6px;
791
+ background: transparent; color: var(--pin); font-size: 10.5px; cursor: pointer;
792
+ }
793
+ .reply { margin-top: 7px; }
794
+ .reply-in {
795
+ width: 100%; resize: vertical; min-height: 40px; padding: 7px 8px;
796
+ border: 1px solid var(--line); border-radius: 9px; background: var(--bg);
797
+ color: var(--ink); font: inherit; font-size: 11.5px;
798
+ }
799
+ .reply-in:focus { outline: none; border-color: var(--accent); }
800
+ .reply-foot { display: flex; align-items: center; gap: 7px; margin-top: 5px; }
801
+ .reply-hint { flex: 1; font-size: 10.5px; color: var(--muted); }
802
+ .reply-send {
803
+ padding: 5px 10px; border: 1px solid var(--accent); border-radius: 8px;
804
+ background: var(--accent); color: #fff; font-size: 11.5px; font-weight: 600; cursor: pointer;
805
+ }
806
+ /* the activity timeline */
807
+ .tl { margin-top: 10px; }
808
+ .tl-h { margin-bottom: 5px; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
809
+ .tl-i { position: relative; padding: 0 0 7px 14px; font-size: 11px; color: var(--muted); }
810
+ .tl-i::before { content: ""; position: absolute; left: 3px; top: 4px; bottom: -3px; width: 1px; background: var(--line); }
811
+ .tl-i:last-child::before { display: none; }
812
+ .tl-dot { position: absolute; left: 0; top: 3px; width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
813
+ .tl-i.tl-resolved .tl-dot { background: #2f9e6a; }
814
+ .tl-i.tl-pr .tl-dot, .tl-i.tl-preview .tl-dot { background: #3f8ae0; }
815
+ .tl-i.tl-review .tl-dot { background: var(--accent); }
816
+ .tl-l { color: var(--ink); }
817
+ .tl-d { display: block; font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; }
818
+ .copyrow { display: flex; gap: 6px; margin-top: 8px; }
819
+ .copy-b {
820
+ flex: 1; padding: 5px 8px; border: 1px solid var(--line); border-radius: 8px;
821
+ background: var(--bg-2); color: var(--muted); font-size: 11px; cursor: pointer;
822
+ }
823
+ .copy-b:hover { border-color: var(--accent); color: var(--ink); }
824
+
763
825
  /* ------------------------------------------------------- hint card (once per view) */
764
826
  .hint {
765
827
  position: relative; margin: 8px 12px 0; padding: 10px 28px 10px 11px;
@@ -2848,15 +2910,15 @@ var LocalStorageAdapter = class {
2848
2910
  };
2849
2911
  }
2850
2912
  /**
2851
- * Keys that hold a comment list. `loupe:dock` also lives under `loupe:` but
2852
- * holds an OBJECT (panel state), so treating every `loupe:` key as a list made
2853
- * update()/remove() throw as soon as the panel persisted anything.
2913
+ * Keys that hold a comment list. `loupe:dock` (panel state) and `loupe:msgs:*`
2914
+ * (replies) share the prefix but are not comment lists — treating them as one made
2915
+ * update()/remove() throw, and would have it hunt a comment id among messages.
2854
2916
  */
2855
2917
  commentKeys() {
2856
2918
  const keys = [];
2857
2919
  for (let i = 0; i < localStorage.length; i++) {
2858
2920
  const k = localStorage.key(i);
2859
- if (k && k.startsWith("loupe:") && k !== "loupe:dock") keys.push(k);
2921
+ if (k && k.startsWith("loupe:") && k !== "loupe:dock" && !k.startsWith("loupe:msgs:")) keys.push(k);
2860
2922
  }
2861
2923
  return keys;
2862
2924
  }
@@ -2868,6 +2930,32 @@ var LocalStorageAdapter = class {
2868
2930
  return [];
2869
2931
  }
2870
2932
  }
2933
+ /** Replies, kept under their own key so they survive a comment being re-saved. */
2934
+ msgKey(threadId) {
2935
+ return `loupe:msgs:${threadId}`;
2936
+ }
2937
+ async listMessages(threadId) {
2938
+ try {
2939
+ const raw = localStorage.getItem(this.msgKey(threadId));
2940
+ const parsed = raw ? JSON.parse(raw) : [];
2941
+ return Array.isArray(parsed) ? parsed : [];
2942
+ } catch {
2943
+ return [];
2944
+ }
2945
+ }
2946
+ async addMessage(threadId, message) {
2947
+ const stored = {
2948
+ id: `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
2949
+ threadId,
2950
+ author: message.author,
2951
+ body: message.body,
2952
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2953
+ };
2954
+ const all = await this.listMessages(threadId);
2955
+ all.push(stored);
2956
+ localStorage.setItem(this.msgKey(threadId), JSON.stringify(all));
2957
+ return stored;
2958
+ }
2871
2959
  async update(id, patch) {
2872
2960
  for (const k of this.commentKeys()) {
2873
2961
  const list = this.parseList(k);
@@ -2963,6 +3051,21 @@ var HttpAdapter = class {
2963
3051
  size: file.size
2964
3052
  };
2965
3053
  }
3054
+ /** Replies on a thread. The comment's own body is message #1, not returned here. */
3055
+ async listMessages(threadId) {
3056
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(threadId)}/messages`, this.opts({ headers: this.headers() }));
3057
+ if (!res.ok) throw new Error(`listMessages failed: ${res.status}`);
3058
+ return await res.json();
3059
+ }
3060
+ async addMessage(threadId, message) {
3061
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(threadId)}/messages`, this.opts({
3062
+ method: "POST",
3063
+ headers: this.headers(),
3064
+ body: JSON.stringify(message)
3065
+ }));
3066
+ if (!res.ok) throw new Error(`addMessage failed: ${res.status}`);
3067
+ return await res.json();
3068
+ }
2966
3069
  async update(id, patch) {
2967
3070
  const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
2968
3071
  method: "PATCH",
@@ -3143,12 +3246,110 @@ function withdraw(record) {
3143
3246
  }
3144
3247
 
3145
3248
  // ../shared/dist/thread.js
3249
+ function firstMessageFromComment(comment) {
3250
+ return {
3251
+ id: `${comment.id}:0`,
3252
+ threadId: comment.id,
3253
+ author: { ...comment.author, type: "user" },
3254
+ body: comment.body,
3255
+ attachments: comment.attachments?.length ? comment.attachments : void 0,
3256
+ createdAt: comment.createdAt
3257
+ };
3258
+ }
3259
+ function threadConversation(comment, replies) {
3260
+ return [firstMessageFromComment(comment), ...replies].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3261
+ }
3146
3262
  function iterationLabel(link) {
3147
3263
  if (!link.parentThreadId || link.iterationType !== "revision")
3148
3264
  return null;
3149
3265
  return `Iteration ${link.iterationNumber ?? 2}`;
3150
3266
  }
3151
3267
 
3268
+ // ../shared/dist/timeline.js
3269
+ function describeTarget(input) {
3270
+ const parts = [];
3271
+ if (input.anchor?.tag)
3272
+ parts.push(`a <${input.anchor.tag}>`);
3273
+ if (input.anchor?.selector)
3274
+ parts.push(`\`${input.anchor.selector}\``);
3275
+ return parts.length ? parts.join(" ") : input.kind === "free" ? "a page note" : "an element";
3276
+ }
3277
+ function threadTimeline(input, messages = []) {
3278
+ const out = [];
3279
+ const latest = messages.length ? messages[messages.length - 1].at : input.createdAt;
3280
+ out.push({
3281
+ at: input.createdAt,
3282
+ kind: "captured",
3283
+ label: "Feedback captured",
3284
+ detail: [input.title, describeTarget(input)].filter(Boolean).join(" \xB7 ")
3285
+ });
3286
+ for (const m of messages) {
3287
+ out.push({
3288
+ at: m.at,
3289
+ kind: "message",
3290
+ label: `${m.authorName} replied`,
3291
+ actor: m.fromAgent ? m.authorName : void 0
3292
+ });
3293
+ }
3294
+ const stage = normalizeStage(input.status);
3295
+ const reached = (s) => STAGE_ORDER.indexOf(stage) >= STAGE_ORDER.indexOf(s);
3296
+ if (input.proposal || reached("in_review")) {
3297
+ const agent = input.proposal?.author ?? "Claude Code";
3298
+ out.push({
3299
+ at: input.proposal?.createdAt ?? latest,
3300
+ kind: "sent",
3301
+ label: `Change ready from ${agent}`,
3302
+ detail: "The rewritten markup is on the thread.",
3303
+ actor: agent
3304
+ });
3305
+ }
3306
+ if (input.pr) {
3307
+ out.push({
3308
+ at: latest,
3309
+ kind: "pr",
3310
+ label: input.pr.number ? `Pull request #${input.pr.number} opened` : "Pull request opened",
3311
+ detail: input.pr.state === "merged" ? "Merged." : input.pr.state === "closed" ? "Closed without merging." : "Waiting on review."
3312
+ });
3313
+ if (input.pr.previewUrl) {
3314
+ out.push({ at: latest, kind: "preview", label: "Preview live", detail: input.pr.previewUrl });
3315
+ }
3316
+ }
3317
+ if (reached("in_review")) {
3318
+ out.push({ at: latest, kind: "review", label: "Waiting on a human review", detail: "Only a person can close this." });
3319
+ }
3320
+ if (stage === "resolved") {
3321
+ out.push({ at: latest, kind: "resolved", label: "Resolved", detail: "A person closed this." });
3322
+ }
3323
+ return out;
3324
+ }
3325
+ var STAGE_ORDER = ["queue", "todo", "in_progress", "in_review", "resolved"];
3326
+ function normalizeStage(status) {
3327
+ if (status === "open")
3328
+ return "queue";
3329
+ if (status === "done")
3330
+ return "resolved";
3331
+ return status;
3332
+ }
3333
+ function threadAsText(input, messages = []) {
3334
+ const lines = [
3335
+ `# ${input.title ?? "Feedback"} (#${input.id})`,
3336
+ "",
3337
+ `- Stage: ${normalizeStage(input.status)}`,
3338
+ `- Page: ${input.url ?? "\u2014"}`
3339
+ ];
3340
+ if (input.anchor?.selector)
3341
+ lines.push(`- Target: \`${input.anchor.selector}\``);
3342
+ if (input.pr?.number)
3343
+ lines.push(`- PR: #${input.pr.number}${input.pr.previewUrl ? ` \xB7 preview ${input.pr.previewUrl}` : ""}`);
3344
+ lines.push("", "## Request", "", input.body);
3345
+ if (messages.length) {
3346
+ lines.push("", "## Conversation", "");
3347
+ for (const m of messages)
3348
+ lines.push(`**${m.authorName}**${m.fromAgent ? " (agent)" : ""} \u2014 ${m.at}`, "", m.body, "");
3349
+ }
3350
+ return lines.join("\n");
3351
+ }
3352
+
3152
3353
  // ../shared/dist/index.js
3153
3354
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
3154
3355
  var STAGE_LABELS = {
@@ -3196,7 +3397,7 @@ function normalizePriority(value) {
3196
3397
  }
3197
3398
 
3198
3399
  // src/app.ts
3199
- var SDK_VERSION = true ? "0.10.21" : "dev";
3400
+ var SDK_VERSION = true ? "0.10.22" : "dev";
3200
3401
  var ACCENTS = [
3201
3402
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3202
3403
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3603,6 +3804,15 @@ var LoupeApp = class {
3603
3804
  /** Draft follow-ups, kept out of the render path so the input never loses focus. */
3604
3805
  this.iterDraft = /* @__PURE__ */ new Map();
3605
3806
  this.voiceTarget = null;
3807
+ /** Replies per thread, loaded lazily when a card is expanded. */
3808
+ this.messages = /* @__PURE__ */ new Map();
3809
+ /** Reply drafts, kept out of the render path so the textarea keeps focus. */
3810
+ this.msgDrafts = /* @__PURE__ */ new Map();
3811
+ /** Optimistic replies awaiting the store. */
3812
+ this.msgPending = /* @__PURE__ */ new Set();
3813
+ /** Optimistic replies the store rejected — offered for retry rather than lost. */
3814
+ this.msgFailed = /* @__PURE__ */ new Set();
3815
+ this.msgErr = /* @__PURE__ */ new Map();
3606
3816
  this.cfg = cfg;
3607
3817
  this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac, cfg.headers, cfg.credentials) : new LocalStorageAdapter();
3608
3818
  }
@@ -5362,11 +5572,15 @@ var LoupeApp = class {
5362
5572
  };
5363
5573
  actions.append(doneBtn, del);
5364
5574
  detail.appendChild(actions);
5575
+ detail.appendChild(this.conversationView(c));
5365
5576
  detail.appendChild(this.generateView(c));
5366
5577
  item.appendChild(detail);
5367
5578
  item.onclick = () => {
5368
5579
  if (open) this.expanded.delete(c.id);
5369
- else this.expanded.add(c.id);
5580
+ else {
5581
+ this.expanded.add(c.id);
5582
+ void this.loadMessages(c);
5583
+ }
5370
5584
  this.renderList();
5371
5585
  };
5372
5586
  return item;
@@ -5827,6 +6041,227 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
5827
6041
  this.voice = null;
5828
6042
  this.voiceTarget = null;
5829
6043
  }
6044
+ /**
6045
+ * The conversation: the replies, a reply box, the activity timeline and the copy
6046
+ * actions. Loaded lazily — a list of twenty cards should not fire twenty requests.
6047
+ */
6048
+ conversationView(c) {
6049
+ const wrap = el("div", "convo");
6050
+ const replies = this.messages.get(c.id);
6051
+ if (replies === void 0) {
6052
+ wrap.appendChild(el("div", "convo-loading", "Loading conversation\u2026"));
6053
+ return wrap;
6054
+ }
6055
+ const asRows = replies.map((m) => ({
6056
+ at: m.createdAt,
6057
+ authorName: m.author.name,
6058
+ body: m.body,
6059
+ fromAgent: m.author.type === "agent"
6060
+ }));
6061
+ const conversation = threadConversation(c, replies);
6062
+ const list = el("div", "msgs");
6063
+ for (const m of conversation) {
6064
+ const fromAgent = m.author.type === "agent";
6065
+ const state = this.msgPending.has(m.id) ? " pending" : this.msgFailed.has(m.id) ? " failed" : "";
6066
+ const row = el("div", "msg" + (fromAgent ? " agent" : "") + state);
6067
+ const initials = (m.author.name || "?").trim().slice(0, 1).toUpperCase();
6068
+ const head = el("div", "msg-head");
6069
+ head.append(
6070
+ el("span", "msg-av" + (fromAgent ? " agent" : ""), initials),
6071
+ el("b", "msg-name", m.author.name),
6072
+ el("span", "msg-when", fmtAgo(m.createdAt))
6073
+ );
6074
+ if (fromAgent) head.appendChild(el("span", "msg-tag", "agent"));
6075
+ row.append(head, el("div", "msg-body", m.body));
6076
+ if (this.msgPending.has(m.id)) row.appendChild(el("div", "msg-state", "Sending\u2026"));
6077
+ if (this.msgFailed.has(m.id)) {
6078
+ const failed = el("div", "msg-state failed");
6079
+ failed.append(document.createTextNode(this.msgErr.get(m.id) ?? "Could not send."));
6080
+ const retry = el("button", "msg-retry", "Retry");
6081
+ retry.onclick = (e) => {
6082
+ e.stopPropagation();
6083
+ void this.resend(c, m.id);
6084
+ };
6085
+ failed.appendChild(retry);
6086
+ row.appendChild(failed);
6087
+ }
6088
+ list.appendChild(row);
6089
+ }
6090
+ wrap.appendChild(list);
6091
+ const reply = el("div", "reply");
6092
+ const input = el("textarea", "reply-in");
6093
+ input.rows = 2;
6094
+ input.placeholder = "Reply\u2026 use @ to mention";
6095
+ input.value = this.msgDrafts.get(c.id) ?? "";
6096
+ input.oninput = () => this.msgDrafts.set(c.id, input.value);
6097
+ input.addEventListener("click", (e) => e.stopPropagation());
6098
+ input.onkeydown = (e) => {
6099
+ if (e.key === "Enter" && !e.shiftKey) {
6100
+ e.preventDefault();
6101
+ void this.sendReply(c, input.value);
6102
+ }
6103
+ };
6104
+ const foot = el("div", "reply-foot");
6105
+ const send = el("button", "reply-send", "\u27A4 Send");
6106
+ send.setAttribute("aria-label", "Send reply");
6107
+ send.onclick = (e) => {
6108
+ e.stopPropagation();
6109
+ void this.sendReply(c, input.value);
6110
+ };
6111
+ foot.append(el("span", "reply-hint", "@ to mention"), send);
6112
+ reply.append(input, foot);
6113
+ wrap.appendChild(reply);
6114
+ const timeline = threadTimeline(c, asRows);
6115
+ const tl = el("div", "tl");
6116
+ tl.appendChild(el("div", "tl-h", "Activity"));
6117
+ for (const entry of timeline) {
6118
+ const item = el("div", `tl-i tl-${entry.kind}`);
6119
+ item.append(el("span", "tl-dot"), el("span", "tl-l", entry.label));
6120
+ if (entry.detail) item.appendChild(el("span", "tl-d", entry.detail));
6121
+ tl.appendChild(item);
6122
+ }
6123
+ wrap.appendChild(tl);
6124
+ const copyRow = el("div", "copyrow");
6125
+ const copyTextBtn = el("button", "copy-b", "Copy thread text");
6126
+ copyTextBtn.onclick = (e) => {
6127
+ e.stopPropagation();
6128
+ const text = threadAsText(
6129
+ {
6130
+ id: c.id,
6131
+ body: c.body,
6132
+ title: c.title,
6133
+ url: c.url,
6134
+ status: c.status,
6135
+ createdAt: c.createdAt,
6136
+ kind: c.kind,
6137
+ anchor: { tag: c.anchor?.tag, selector: c.anchor?.cssPath },
6138
+ proposal: c.proposal,
6139
+ pr: c.pr
6140
+ },
6141
+ asRows
6142
+ );
6143
+ void this.copyText(copyTextBtn, text);
6144
+ };
6145
+ const copyImagesBtn = el("button", "copy-b", "Copy images");
6146
+ copyImagesBtn.onclick = (e) => {
6147
+ e.stopPropagation();
6148
+ void this.copyImages(copyImagesBtn, c);
6149
+ };
6150
+ copyRow.append(copyTextBtn, copyImagesBtn);
6151
+ wrap.appendChild(copyRow);
6152
+ return wrap;
6153
+ }
6154
+ /** Fetch replies once, when a card is first expanded. */
6155
+ async loadMessages(c) {
6156
+ if (this.messages.has(c.id)) return;
6157
+ try {
6158
+ this.messages.set(c.id, await this.store.listMessages(c.id));
6159
+ } catch {
6160
+ this.messages.set(c.id, []);
6161
+ }
6162
+ this.renderList();
6163
+ }
6164
+ /**
6165
+ * Post a reply, optimistically.
6166
+ *
6167
+ * The row appears immediately; if the store rejects it the row stays with a Retry,
6168
+ * because losing what someone typed is worse than showing a failed row.
6169
+ */
6170
+ async sendReply(c, raw) {
6171
+ const body = raw.trim();
6172
+ if (!body) return;
6173
+ const author = {
6174
+ id: this.cfg.user.id,
6175
+ name: this.cfg.user.name,
6176
+ email: this.cfg.user.email,
6177
+ type: "user"
6178
+ };
6179
+ const optimistic = {
6180
+ id: `pending-${Date.now().toString(36)}`,
6181
+ threadId: c.id,
6182
+ author,
6183
+ body,
6184
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
6185
+ };
6186
+ this.messages.set(c.id, [...this.messages.get(c.id) ?? [], optimistic]);
6187
+ this.msgPending.add(optimistic.id);
6188
+ this.msgDrafts.delete(c.id);
6189
+ this.renderList();
6190
+ try {
6191
+ const saved = await this.store.addMessage(c.id, { author, body });
6192
+ this.messages.set(c.id, (this.messages.get(c.id) ?? []).map((m) => m.id === optimistic.id ? saved : m));
6193
+ this.msgPending.delete(optimistic.id);
6194
+ this.addActivity({ kind: "message.create", label: `Replied on \u201C${c.title || body.slice(0, 40)}\u201D` });
6195
+ } catch (e) {
6196
+ this.msgPending.delete(optimistic.id);
6197
+ this.msgFailed.add(optimistic.id);
6198
+ this.msgErr.set(optimistic.id, e instanceof Error ? e.message : "Could not send.");
6199
+ }
6200
+ this.renderList();
6201
+ }
6202
+ /** Try a failed optimistic reply again, in place. */
6203
+ async resend(c, id) {
6204
+ const found = (this.messages.get(c.id) ?? []).find((m) => m.id === id);
6205
+ if (!found) return;
6206
+ this.msgFailed.delete(id);
6207
+ this.msgErr.delete(id);
6208
+ this.msgPending.add(id);
6209
+ this.renderList();
6210
+ try {
6211
+ const saved = await this.store.addMessage(c.id, { author: found.author, body: found.body });
6212
+ this.messages.set(c.id, (this.messages.get(c.id) ?? []).map((m) => m.id === id ? saved : m));
6213
+ this.msgPending.delete(id);
6214
+ } catch (e) {
6215
+ this.msgPending.delete(id);
6216
+ this.msgFailed.add(id);
6217
+ this.msgErr.set(id, e instanceof Error ? e.message : "Could not send.");
6218
+ }
6219
+ this.renderList();
6220
+ }
6221
+ /** Copy text, and say so — silence looks like a no-op. */
6222
+ async copyText(button, text) {
6223
+ const original = button.textContent;
6224
+ try {
6225
+ if (!navigator.clipboard?.writeText) throw new Error("clipboard unavailable");
6226
+ await navigator.clipboard.writeText(text);
6227
+ button.textContent = "Copied \u2713";
6228
+ } catch {
6229
+ button.textContent = "Copy failed";
6230
+ }
6231
+ setTimeout(() => {
6232
+ button.textContent = original;
6233
+ }, 1500);
6234
+ }
6235
+ /**
6236
+ * Copy the captured images. Best-effort and reported as such: an image clipboard
6237
+ * write needs a secure context and a user gesture, and a silent failure would look
6238
+ * like it worked.
6239
+ */
6240
+ async copyImages(button, c) {
6241
+ const original = button.textContent;
6242
+ const sources = [c.screenshot, ...(c.attachments ?? []).filter((a) => a.kind === "image").map((a) => a.url)].filter((s) => !!s);
6243
+ if (!sources.length) {
6244
+ button.textContent = "No images";
6245
+ setTimeout(() => {
6246
+ button.textContent = original;
6247
+ }, 1500);
6248
+ return;
6249
+ }
6250
+ try {
6251
+ const blobs = await Promise.all(sources.slice(0, 4).map(async (src) => (await fetch(src)).blob()));
6252
+ const items = {};
6253
+ blobs.forEach((blob, i) => {
6254
+ items[i === 0 ? "image/png" : `image/png-${i}`] = blob;
6255
+ });
6256
+ await navigator.clipboard.write([new window.ClipboardItem(items)]);
6257
+ button.textContent = "Copied \u2713";
6258
+ } catch {
6259
+ button.textContent = "Copy failed";
6260
+ }
6261
+ setTimeout(() => {
6262
+ button.textContent = original;
6263
+ }, 1500);
6264
+ }
5830
6265
  flash(id) {
5831
6266
  const c = this.comments.find((x) => x.id === id);
5832
6267
  const pin = this.pins.get(id);