@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.global.js +441 -6
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +441 -6
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.global.js
CHANGED
|
@@ -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`
|
|
2886
|
-
*
|
|
2887
|
-
* update()/remove() throw
|
|
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.
|
|
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
|
|
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);
|