@loupekit/sdk 0.10.21-next.67 → 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.
@@ -794,6 +794,68 @@ 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
+ /* ------------------------------------------------------------- conversation */
798
+ .convo { margin-top: 8px; }
799
+ .convo-loading { padding: 10px; text-align: center; font-size: 11.5px; color: var(--muted); }
800
+ .msgs { display: flex; flex-direction: column; gap: 6px; }
801
+ .msg { padding: 7px 8px; border-radius: 9px; background: var(--bg-2); border: 1px solid transparent; }
802
+ /* An agent's reply is called out with a left accent bar and a lighter container \u2014
803
+ it should be obvious at a glance who you are talking to. */
804
+ .msg.agent { border-left: 3px solid var(--accent); background: var(--bg-3); }
805
+ .msg.pending { opacity: .65; }
806
+ .msg.failed { border-color: var(--pin); }
807
+ .msg-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
808
+ .msg-av {
809
+ width: 20px; height: 20px; border-radius: 50%; flex: none;
810
+ display: grid; place-items: center; background: var(--bg-3); color: var(--muted);
811
+ font-size: 10px; font-weight: 700;
812
+ }
813
+ .msg-av.agent { background: var(--accent); color: #fff; }
814
+ .msg-name { font-size: 11.5px; color: var(--ink); }
815
+ .msg-when { flex: 1; font-size: 10.5px; color: var(--muted); }
816
+ .msg-tag {
817
+ padding: 0 5px; border-radius: 999px; background: var(--accent); color: #fff;
818
+ font-size: 9px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
819
+ }
820
+ .msg-body { font-size: 11.5px; line-height: 1.45; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
821
+ .msg-state { margin-top: 4px; font-size: 10.5px; color: var(--muted); }
822
+ .msg-state.failed { color: var(--pin); }
823
+ .msg-retry {
824
+ margin-left: 6px; padding: 1px 7px; border: 1px solid var(--pin); border-radius: 6px;
825
+ background: transparent; color: var(--pin); font-size: 10.5px; cursor: pointer;
826
+ }
827
+ .reply { margin-top: 7px; }
828
+ .reply-in {
829
+ width: 100%; resize: vertical; min-height: 40px; padding: 7px 8px;
830
+ border: 1px solid var(--line); border-radius: 9px; background: var(--bg);
831
+ color: var(--ink); font: inherit; font-size: 11.5px;
832
+ }
833
+ .reply-in:focus { outline: none; border-color: var(--accent); }
834
+ .reply-foot { display: flex; align-items: center; gap: 7px; margin-top: 5px; }
835
+ .reply-hint { flex: 1; font-size: 10.5px; color: var(--muted); }
836
+ .reply-send {
837
+ padding: 5px 10px; border: 1px solid var(--accent); border-radius: 8px;
838
+ background: var(--accent); color: #fff; font-size: 11.5px; font-weight: 600; cursor: pointer;
839
+ }
840
+ /* the activity timeline */
841
+ .tl { margin-top: 10px; }
842
+ .tl-h { margin-bottom: 5px; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
843
+ .tl-i { position: relative; padding: 0 0 7px 14px; font-size: 11px; color: var(--muted); }
844
+ .tl-i::before { content: ""; position: absolute; left: 3px; top: 4px; bottom: -3px; width: 1px; background: var(--line); }
845
+ .tl-i:last-child::before { display: none; }
846
+ .tl-dot { position: absolute; left: 0; top: 3px; width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
847
+ .tl-i.tl-resolved .tl-dot { background: #2f9e6a; }
848
+ .tl-i.tl-pr .tl-dot, .tl-i.tl-preview .tl-dot { background: #3f8ae0; }
849
+ .tl-i.tl-review .tl-dot { background: var(--accent); }
850
+ .tl-l { color: var(--ink); }
851
+ .tl-d { display: block; font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; }
852
+ .copyrow { display: flex; gap: 6px; margin-top: 8px; }
853
+ .copy-b {
854
+ flex: 1; padding: 5px 8px; border: 1px solid var(--line); border-radius: 8px;
855
+ background: var(--bg-2); color: var(--muted); font-size: 11px; cursor: pointer;
856
+ }
857
+ .copy-b:hover { border-color: var(--accent); color: var(--ink); }
858
+
797
859
  /* ------------------------------------------------------- hint card (once per view) */
798
860
  .hint {
799
861
  position: relative; margin: 8px 12px 0; padding: 10px 28px 10px 11px;
@@ -2882,15 +2944,15 @@ var Loupe = (() => {
2882
2944
  };
2883
2945
  }
2884
2946
  /**
2885
- * Keys that hold a comment list. `loupe:dock` also lives under `loupe:` but
2886
- * holds an OBJECT (panel state), so treating every `loupe:` key as a list made
2887
- * update()/remove() throw as soon as the panel persisted anything.
2947
+ * Keys that hold a comment list. `loupe:dock` (panel state) and `loupe:msgs:*`
2948
+ * (replies) share the prefix but are not comment lists — treating them as one made
2949
+ * update()/remove() throw, and would have it hunt a comment id among messages.
2888
2950
  */
2889
2951
  commentKeys() {
2890
2952
  const keys = [];
2891
2953
  for (let i = 0; i < localStorage.length; i++) {
2892
2954
  const k = localStorage.key(i);
2893
- if (k && k.startsWith("loupe:") && k !== "loupe:dock") keys.push(k);
2955
+ if (k && k.startsWith("loupe:") && k !== "loupe:dock" && !k.startsWith("loupe:msgs:")) keys.push(k);
2894
2956
  }
2895
2957
  return keys;
2896
2958
  }
@@ -2902,6 +2964,32 @@ var Loupe = (() => {
2902
2964
  return [];
2903
2965
  }
2904
2966
  }
2967
+ /** Replies, kept under their own key so they survive a comment being re-saved. */
2968
+ msgKey(threadId) {
2969
+ return `loupe:msgs:${threadId}`;
2970
+ }
2971
+ async listMessages(threadId) {
2972
+ try {
2973
+ const raw = localStorage.getItem(this.msgKey(threadId));
2974
+ const parsed = raw ? JSON.parse(raw) : [];
2975
+ return Array.isArray(parsed) ? parsed : [];
2976
+ } catch {
2977
+ return [];
2978
+ }
2979
+ }
2980
+ async addMessage(threadId, message) {
2981
+ const stored = {
2982
+ id: `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
2983
+ threadId,
2984
+ author: message.author,
2985
+ body: message.body,
2986
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2987
+ };
2988
+ const all = await this.listMessages(threadId);
2989
+ all.push(stored);
2990
+ localStorage.setItem(this.msgKey(threadId), JSON.stringify(all));
2991
+ return stored;
2992
+ }
2905
2993
  async update(id, patch) {
2906
2994
  for (const k of this.commentKeys()) {
2907
2995
  const list = this.parseList(k);
@@ -2997,6 +3085,21 @@ var Loupe = (() => {
2997
3085
  size: file.size
2998
3086
  };
2999
3087
  }
3088
+ /** Replies on a thread. The comment's own body is message #1, not returned here. */
3089
+ async listMessages(threadId) {
3090
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(threadId)}/messages`, this.opts({ headers: this.headers() }));
3091
+ if (!res.ok) throw new Error(`listMessages failed: ${res.status}`);
3092
+ return await res.json();
3093
+ }
3094
+ async addMessage(threadId, message) {
3095
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(threadId)}/messages`, this.opts({
3096
+ method: "POST",
3097
+ headers: this.headers(),
3098
+ body: JSON.stringify(message)
3099
+ }));
3100
+ if (!res.ok) throw new Error(`addMessage failed: ${res.status}`);
3101
+ return await res.json();
3102
+ }
3000
3103
  async update(id, patch) {
3001
3104
  const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3002
3105
  method: "PATCH",
@@ -3177,12 +3280,110 @@ var Loupe = (() => {
3177
3280
  }
3178
3281
 
3179
3282
  // ../shared/dist/thread.js
3283
+ function firstMessageFromComment(comment) {
3284
+ return {
3285
+ id: `${comment.id}:0`,
3286
+ threadId: comment.id,
3287
+ author: { ...comment.author, type: "user" },
3288
+ body: comment.body,
3289
+ attachments: comment.attachments?.length ? comment.attachments : void 0,
3290
+ createdAt: comment.createdAt
3291
+ };
3292
+ }
3293
+ function threadConversation(comment, replies) {
3294
+ return [firstMessageFromComment(comment), ...replies].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3295
+ }
3180
3296
  function iterationLabel(link) {
3181
3297
  if (!link.parentThreadId || link.iterationType !== "revision")
3182
3298
  return null;
3183
3299
  return `Iteration ${link.iterationNumber ?? 2}`;
3184
3300
  }
3185
3301
 
3302
+ // ../shared/dist/timeline.js
3303
+ function describeTarget(input) {
3304
+ const parts = [];
3305
+ if (input.anchor?.tag)
3306
+ parts.push(`a <${input.anchor.tag}>`);
3307
+ if (input.anchor?.selector)
3308
+ parts.push(`\`${input.anchor.selector}\``);
3309
+ return parts.length ? parts.join(" ") : input.kind === "free" ? "a page note" : "an element";
3310
+ }
3311
+ function threadTimeline(input, messages = []) {
3312
+ const out = [];
3313
+ const latest = messages.length ? messages[messages.length - 1].at : input.createdAt;
3314
+ out.push({
3315
+ at: input.createdAt,
3316
+ kind: "captured",
3317
+ label: "Feedback captured",
3318
+ detail: [input.title, describeTarget(input)].filter(Boolean).join(" \xB7 ")
3319
+ });
3320
+ for (const m of messages) {
3321
+ out.push({
3322
+ at: m.at,
3323
+ kind: "message",
3324
+ label: `${m.authorName} replied`,
3325
+ actor: m.fromAgent ? m.authorName : void 0
3326
+ });
3327
+ }
3328
+ const stage = normalizeStage(input.status);
3329
+ const reached = (s) => STAGE_ORDER.indexOf(stage) >= STAGE_ORDER.indexOf(s);
3330
+ if (input.proposal || reached("in_review")) {
3331
+ const agent = input.proposal?.author ?? "Claude Code";
3332
+ out.push({
3333
+ at: input.proposal?.createdAt ?? latest,
3334
+ kind: "sent",
3335
+ label: `Change ready from ${agent}`,
3336
+ detail: "The rewritten markup is on the thread.",
3337
+ actor: agent
3338
+ });
3339
+ }
3340
+ if (input.pr) {
3341
+ out.push({
3342
+ at: latest,
3343
+ kind: "pr",
3344
+ label: input.pr.number ? `Pull request #${input.pr.number} opened` : "Pull request opened",
3345
+ detail: input.pr.state === "merged" ? "Merged." : input.pr.state === "closed" ? "Closed without merging." : "Waiting on review."
3346
+ });
3347
+ if (input.pr.previewUrl) {
3348
+ out.push({ at: latest, kind: "preview", label: "Preview live", detail: input.pr.previewUrl });
3349
+ }
3350
+ }
3351
+ if (reached("in_review")) {
3352
+ out.push({ at: latest, kind: "review", label: "Waiting on a human review", detail: "Only a person can close this." });
3353
+ }
3354
+ if (stage === "resolved") {
3355
+ out.push({ at: latest, kind: "resolved", label: "Resolved", detail: "A person closed this." });
3356
+ }
3357
+ return out;
3358
+ }
3359
+ var STAGE_ORDER = ["queue", "todo", "in_progress", "in_review", "resolved"];
3360
+ function normalizeStage(status) {
3361
+ if (status === "open")
3362
+ return "queue";
3363
+ if (status === "done")
3364
+ return "resolved";
3365
+ return status;
3366
+ }
3367
+ function threadAsText(input, messages = []) {
3368
+ const lines = [
3369
+ `# ${input.title ?? "Feedback"} (#${input.id})`,
3370
+ "",
3371
+ `- Stage: ${normalizeStage(input.status)}`,
3372
+ `- Page: ${input.url ?? "\u2014"}`
3373
+ ];
3374
+ if (input.anchor?.selector)
3375
+ lines.push(`- Target: \`${input.anchor.selector}\``);
3376
+ if (input.pr?.number)
3377
+ lines.push(`- PR: #${input.pr.number}${input.pr.previewUrl ? ` \xB7 preview ${input.pr.previewUrl}` : ""}`);
3378
+ lines.push("", "## Request", "", input.body);
3379
+ if (messages.length) {
3380
+ lines.push("", "## Conversation", "");
3381
+ for (const m of messages)
3382
+ lines.push(`**${m.authorName}**${m.fromAgent ? " (agent)" : ""} \u2014 ${m.at}`, "", m.body, "");
3383
+ }
3384
+ return lines.join("\n");
3385
+ }
3386
+
3186
3387
  // ../shared/dist/index.js
3187
3388
  var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
3188
3389
  var STAGE_LABELS = {
@@ -3230,7 +3431,7 @@ var Loupe = (() => {
3230
3431
  }
3231
3432
 
3232
3433
  // src/app.ts
3233
- var SDK_VERSION = true ? "0.10.21" : "dev";
3434
+ var SDK_VERSION = true ? "0.10.22" : "dev";
3234
3435
  var ACCENTS = [
3235
3436
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3236
3437
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3637,6 +3838,15 @@ var Loupe = (() => {
3637
3838
  /** Draft follow-ups, kept out of the render path so the input never loses focus. */
3638
3839
  this.iterDraft = /* @__PURE__ */ new Map();
3639
3840
  this.voiceTarget = null;
3841
+ /** Replies per thread, loaded lazily when a card is expanded. */
3842
+ this.messages = /* @__PURE__ */ new Map();
3843
+ /** Reply drafts, kept out of the render path so the textarea keeps focus. */
3844
+ this.msgDrafts = /* @__PURE__ */ new Map();
3845
+ /** Optimistic replies awaiting the store. */
3846
+ this.msgPending = /* @__PURE__ */ new Set();
3847
+ /** Optimistic replies the store rejected — offered for retry rather than lost. */
3848
+ this.msgFailed = /* @__PURE__ */ new Set();
3849
+ this.msgErr = /* @__PURE__ */ new Map();
3640
3850
  this.cfg = cfg;
3641
3851
  this.store = cfg.apiBase ? new HttpAdapter(cfg.apiBase, cfg.user, cfg.userHmac, cfg.headers, cfg.credentials) : new LocalStorageAdapter();
3642
3852
  }
@@ -5396,11 +5606,15 @@ var Loupe = (() => {
5396
5606
  };
5397
5607
  actions.append(doneBtn, del);
5398
5608
  detail.appendChild(actions);
5609
+ detail.appendChild(this.conversationView(c));
5399
5610
  detail.appendChild(this.generateView(c));
5400
5611
  item.appendChild(detail);
5401
5612
  item.onclick = () => {
5402
5613
  if (open) this.expanded.delete(c.id);
5403
- else this.expanded.add(c.id);
5614
+ else {
5615
+ this.expanded.add(c.id);
5616
+ void this.loadMessages(c);
5617
+ }
5404
5618
  this.renderList();
5405
5619
  };
5406
5620
  return item;
@@ -5861,6 +6075,227 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
5861
6075
  this.voice = null;
5862
6076
  this.voiceTarget = null;
5863
6077
  }
6078
+ /**
6079
+ * The conversation: the replies, a reply box, the activity timeline and the copy
6080
+ * actions. Loaded lazily — a list of twenty cards should not fire twenty requests.
6081
+ */
6082
+ conversationView(c) {
6083
+ const wrap = el("div", "convo");
6084
+ const replies = this.messages.get(c.id);
6085
+ if (replies === void 0) {
6086
+ wrap.appendChild(el("div", "convo-loading", "Loading conversation\u2026"));
6087
+ return wrap;
6088
+ }
6089
+ const asRows = replies.map((m) => ({
6090
+ at: m.createdAt,
6091
+ authorName: m.author.name,
6092
+ body: m.body,
6093
+ fromAgent: m.author.type === "agent"
6094
+ }));
6095
+ const conversation = threadConversation(c, replies);
6096
+ const list = el("div", "msgs");
6097
+ for (const m of conversation) {
6098
+ const fromAgent = m.author.type === "agent";
6099
+ const state = this.msgPending.has(m.id) ? " pending" : this.msgFailed.has(m.id) ? " failed" : "";
6100
+ const row = el("div", "msg" + (fromAgent ? " agent" : "") + state);
6101
+ const initials = (m.author.name || "?").trim().slice(0, 1).toUpperCase();
6102
+ const head = el("div", "msg-head");
6103
+ head.append(
6104
+ el("span", "msg-av" + (fromAgent ? " agent" : ""), initials),
6105
+ el("b", "msg-name", m.author.name),
6106
+ el("span", "msg-when", fmtAgo(m.createdAt))
6107
+ );
6108
+ if (fromAgent) head.appendChild(el("span", "msg-tag", "agent"));
6109
+ row.append(head, el("div", "msg-body", m.body));
6110
+ if (this.msgPending.has(m.id)) row.appendChild(el("div", "msg-state", "Sending\u2026"));
6111
+ if (this.msgFailed.has(m.id)) {
6112
+ const failed = el("div", "msg-state failed");
6113
+ failed.append(document.createTextNode(this.msgErr.get(m.id) ?? "Could not send."));
6114
+ const retry = el("button", "msg-retry", "Retry");
6115
+ retry.onclick = (e) => {
6116
+ e.stopPropagation();
6117
+ void this.resend(c, m.id);
6118
+ };
6119
+ failed.appendChild(retry);
6120
+ row.appendChild(failed);
6121
+ }
6122
+ list.appendChild(row);
6123
+ }
6124
+ wrap.appendChild(list);
6125
+ const reply = el("div", "reply");
6126
+ const input = el("textarea", "reply-in");
6127
+ input.rows = 2;
6128
+ input.placeholder = "Reply\u2026 use @ to mention";
6129
+ input.value = this.msgDrafts.get(c.id) ?? "";
6130
+ input.oninput = () => this.msgDrafts.set(c.id, input.value);
6131
+ input.addEventListener("click", (e) => e.stopPropagation());
6132
+ input.onkeydown = (e) => {
6133
+ if (e.key === "Enter" && !e.shiftKey) {
6134
+ e.preventDefault();
6135
+ void this.sendReply(c, input.value);
6136
+ }
6137
+ };
6138
+ const foot = el("div", "reply-foot");
6139
+ const send = el("button", "reply-send", "\u27A4 Send");
6140
+ send.setAttribute("aria-label", "Send reply");
6141
+ send.onclick = (e) => {
6142
+ e.stopPropagation();
6143
+ void this.sendReply(c, input.value);
6144
+ };
6145
+ foot.append(el("span", "reply-hint", "@ to mention"), send);
6146
+ reply.append(input, foot);
6147
+ wrap.appendChild(reply);
6148
+ const timeline = threadTimeline(c, asRows);
6149
+ const tl = el("div", "tl");
6150
+ tl.appendChild(el("div", "tl-h", "Activity"));
6151
+ for (const entry of timeline) {
6152
+ const item = el("div", `tl-i tl-${entry.kind}`);
6153
+ item.append(el("span", "tl-dot"), el("span", "tl-l", entry.label));
6154
+ if (entry.detail) item.appendChild(el("span", "tl-d", entry.detail));
6155
+ tl.appendChild(item);
6156
+ }
6157
+ wrap.appendChild(tl);
6158
+ const copyRow = el("div", "copyrow");
6159
+ const copyTextBtn = el("button", "copy-b", "Copy thread text");
6160
+ copyTextBtn.onclick = (e) => {
6161
+ e.stopPropagation();
6162
+ const text = threadAsText(
6163
+ {
6164
+ id: c.id,
6165
+ body: c.body,
6166
+ title: c.title,
6167
+ url: c.url,
6168
+ status: c.status,
6169
+ createdAt: c.createdAt,
6170
+ kind: c.kind,
6171
+ anchor: { tag: c.anchor?.tag, selector: c.anchor?.cssPath },
6172
+ proposal: c.proposal,
6173
+ pr: c.pr
6174
+ },
6175
+ asRows
6176
+ );
6177
+ void this.copyText(copyTextBtn, text);
6178
+ };
6179
+ const copyImagesBtn = el("button", "copy-b", "Copy images");
6180
+ copyImagesBtn.onclick = (e) => {
6181
+ e.stopPropagation();
6182
+ void this.copyImages(copyImagesBtn, c);
6183
+ };
6184
+ copyRow.append(copyTextBtn, copyImagesBtn);
6185
+ wrap.appendChild(copyRow);
6186
+ return wrap;
6187
+ }
6188
+ /** Fetch replies once, when a card is first expanded. */
6189
+ async loadMessages(c) {
6190
+ if (this.messages.has(c.id)) return;
6191
+ try {
6192
+ this.messages.set(c.id, await this.store.listMessages(c.id));
6193
+ } catch {
6194
+ this.messages.set(c.id, []);
6195
+ }
6196
+ this.renderList();
6197
+ }
6198
+ /**
6199
+ * Post a reply, optimistically.
6200
+ *
6201
+ * The row appears immediately; if the store rejects it the row stays with a Retry,
6202
+ * because losing what someone typed is worse than showing a failed row.
6203
+ */
6204
+ async sendReply(c, raw) {
6205
+ const body = raw.trim();
6206
+ if (!body) return;
6207
+ const author = {
6208
+ id: this.cfg.user.id,
6209
+ name: this.cfg.user.name,
6210
+ email: this.cfg.user.email,
6211
+ type: "user"
6212
+ };
6213
+ const optimistic = {
6214
+ id: `pending-${Date.now().toString(36)}`,
6215
+ threadId: c.id,
6216
+ author,
6217
+ body,
6218
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
6219
+ };
6220
+ this.messages.set(c.id, [...this.messages.get(c.id) ?? [], optimistic]);
6221
+ this.msgPending.add(optimistic.id);
6222
+ this.msgDrafts.delete(c.id);
6223
+ this.renderList();
6224
+ try {
6225
+ const saved = await this.store.addMessage(c.id, { author, body });
6226
+ this.messages.set(c.id, (this.messages.get(c.id) ?? []).map((m) => m.id === optimistic.id ? saved : m));
6227
+ this.msgPending.delete(optimistic.id);
6228
+ this.addActivity({ kind: "message.create", label: `Replied on \u201C${c.title || body.slice(0, 40)}\u201D` });
6229
+ } catch (e) {
6230
+ this.msgPending.delete(optimistic.id);
6231
+ this.msgFailed.add(optimistic.id);
6232
+ this.msgErr.set(optimistic.id, e instanceof Error ? e.message : "Could not send.");
6233
+ }
6234
+ this.renderList();
6235
+ }
6236
+ /** Try a failed optimistic reply again, in place. */
6237
+ async resend(c, id) {
6238
+ const found = (this.messages.get(c.id) ?? []).find((m) => m.id === id);
6239
+ if (!found) return;
6240
+ this.msgFailed.delete(id);
6241
+ this.msgErr.delete(id);
6242
+ this.msgPending.add(id);
6243
+ this.renderList();
6244
+ try {
6245
+ const saved = await this.store.addMessage(c.id, { author: found.author, body: found.body });
6246
+ this.messages.set(c.id, (this.messages.get(c.id) ?? []).map((m) => m.id === id ? saved : m));
6247
+ this.msgPending.delete(id);
6248
+ } catch (e) {
6249
+ this.msgPending.delete(id);
6250
+ this.msgFailed.add(id);
6251
+ this.msgErr.set(id, e instanceof Error ? e.message : "Could not send.");
6252
+ }
6253
+ this.renderList();
6254
+ }
6255
+ /** Copy text, and say so — silence looks like a no-op. */
6256
+ async copyText(button, text) {
6257
+ const original = button.textContent;
6258
+ try {
6259
+ if (!navigator.clipboard?.writeText) throw new Error("clipboard unavailable");
6260
+ await navigator.clipboard.writeText(text);
6261
+ button.textContent = "Copied \u2713";
6262
+ } catch {
6263
+ button.textContent = "Copy failed";
6264
+ }
6265
+ setTimeout(() => {
6266
+ button.textContent = original;
6267
+ }, 1500);
6268
+ }
6269
+ /**
6270
+ * Copy the captured images. Best-effort and reported as such: an image clipboard
6271
+ * write needs a secure context and a user gesture, and a silent failure would look
6272
+ * like it worked.
6273
+ */
6274
+ async copyImages(button, c) {
6275
+ const original = button.textContent;
6276
+ const sources = [c.screenshot, ...(c.attachments ?? []).filter((a) => a.kind === "image").map((a) => a.url)].filter((s) => !!s);
6277
+ if (!sources.length) {
6278
+ button.textContent = "No images";
6279
+ setTimeout(() => {
6280
+ button.textContent = original;
6281
+ }, 1500);
6282
+ return;
6283
+ }
6284
+ try {
6285
+ const blobs = await Promise.all(sources.slice(0, 4).map(async (src) => (await fetch(src)).blob()));
6286
+ const items = {};
6287
+ blobs.forEach((blob, i) => {
6288
+ items[i === 0 ? "image/png" : `image/png-${i}`] = blob;
6289
+ });
6290
+ await navigator.clipboard.write([new window.ClipboardItem(items)]);
6291
+ button.textContent = "Copied \u2713";
6292
+ } catch {
6293
+ button.textContent = "Copy failed";
6294
+ }
6295
+ setTimeout(() => {
6296
+ button.textContent = original;
6297
+ }, 1500);
6298
+ }
5864
6299
  flash(id) {
5865
6300
  const c = this.comments.find((x) => x.id === id);
5866
6301
  const pin = this.pins.get(id);