@loupekit/sdk 0.10.23-next.69 → 0.10.24-next.70

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
+ /* reactions */
764
+ .rxns { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 6px; position: relative; }
765
+ .rxn {
766
+ display: inline-flex; align-items: center; gap: 3px; padding: 1px 7px;
767
+ border: 1px solid var(--line); border-radius: 999px; background: var(--bg);
768
+ color: var(--ink); font-size: 11px; cursor: pointer; line-height: 1.7;
769
+ }
770
+ .rxn:hover { border-color: var(--accent); }
771
+ .rxn.mine { border-color: var(--accent); background: var(--accent-soft); }
772
+ .rxn-n { color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; }
773
+ .rxn.mine .rxn-n { color: var(--accent); }
774
+ .rxn-add {
775
+ width: 20px; height: 20px; padding: 0; border: 1px dashed var(--line); border-radius: 50%;
776
+ background: transparent; color: var(--muted); font-size: 11px; cursor: pointer; line-height: 1;
777
+ }
778
+ .rxn-add:hover { border-color: var(--accent); color: var(--accent); }
779
+ .rxn-pick {
780
+ display: flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 999px;
781
+ background: var(--bg); box-shadow: 0 4px 14px rgb(0 0 0 / 18%); z-index: 3;
782
+ }
783
+ .rxn-opt {
784
+ width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%;
785
+ background: transparent; font-size: 14px; cursor: pointer; line-height: 1;
786
+ }
787
+ .rxn-opt:hover { background: var(--accent-soft); }
788
+
789
+ /* who else is here */
790
+ .peers { display: flex; align-items: center; gap: -2px; margin-right: 8px; }
791
+ .peer-av {
792
+ display: inline-flex; align-items: center; justify-content: center;
793
+ width: 20px; height: 20px; margin-left: -5px; border-radius: 50%;
794
+ border: 2px solid var(--bg); background: var(--accent); color: #fff;
795
+ font-size: 9px; font-weight: 700; letter-spacing: .02em;
796
+ }
797
+ .peer-av:first-child { margin-left: 0; }
798
+ .peer-more { margin-left: 3px; font-size: 10px; color: var(--muted); }
799
+
763
800
  /* in-app mentions */
764
801
  .hnotif { margin-top: 10px; }
765
802
  .nf-head { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; font-size: 11.5px; color: var(--ink); }
@@ -2893,41 +2930,498 @@ function cropRegion(dataUrl, rect, ox, oy, scale, redact) {
2893
2930
  });
2894
2931
  }
2895
2932
 
2896
- // src/store.ts
2897
- var LocalStorageAdapter = class {
2898
- key(projectKey, url) {
2899
- return `loupe:${projectKey}:${url}`;
2933
+ // ../shared/dist/activity.js
2934
+ var ACTIVITY_STATUS_LABELS = {
2935
+ idle: "Idle",
2936
+ working: "Working",
2937
+ error: "Error"
2938
+ };
2939
+ function formatDuration(ms) {
2940
+ if (!ms || ms < 0)
2941
+ return "\u2014";
2942
+ const s = Math.round(ms / 1e3);
2943
+ if (s < 60)
2944
+ return `${s}s`;
2945
+ const m = Math.floor(s / 60);
2946
+ const rest = s % 60;
2947
+ if (m < 60)
2948
+ return rest ? `${m}m ${rest}s` : `${m}m`;
2949
+ const h = Math.floor(m / 60);
2950
+ return `${h}h ${m % 60}m`;
2951
+ }
2952
+ function summarizeActivity(events, status = "idle", now = Date.now()) {
2953
+ const files = /* @__PURE__ */ new Set();
2954
+ const kinds = /* @__PURE__ */ new Map();
2955
+ let errors = 0;
2956
+ let first = Infinity;
2957
+ for (const e of events) {
2958
+ for (const f of e.files ?? [])
2959
+ files.add(f);
2960
+ kinds.set(e.kind, (kinds.get(e.kind) ?? 0) + 1);
2961
+ if (e.level === "error")
2962
+ errors++;
2963
+ const t = Date.parse(e.at);
2964
+ if (!Number.isNaN(t))
2965
+ first = Math.min(first, t);
2900
2966
  }
2901
- readAll(projectKey, url) {
2902
- try {
2903
- const raw = localStorage.getItem(this.key(projectKey, url));
2904
- return raw ? JSON.parse(raw) : [];
2905
- } catch {
2906
- return [];
2967
+ return {
2968
+ status,
2969
+ events: events.length,
2970
+ errors,
2971
+ files: files.size,
2972
+ durationMs: events.length && first !== Infinity ? Math.max(0, now - first) : 0,
2973
+ byKind: [...kinds.entries()].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind))
2974
+ };
2975
+ }
2976
+
2977
+ // ../shared/dist/lifecycle.js
2978
+ var LIFECYCLE_LABELS = {
2979
+ sent: "Sent to agent",
2980
+ in_pr: "In PR",
2981
+ preview: "Review preview",
2982
+ reviewed: "Reviewed"
2983
+ };
2984
+ function lifecycle(c) {
2985
+ const hasProposal = !!c.proposal;
2986
+ const pr = c.pr ?? void 0;
2987
+ const withPr = (stage) => {
2988
+ const out = { stage, label: LIFECYCLE_LABELS[stage] };
2989
+ if (pr) {
2990
+ out.pr = pr;
2991
+ if (typeof pr.checksTotal === "number" && pr.checksTotal > 0) {
2992
+ const passed = Math.max(0, Math.min(pr.checksTotal, pr.checksPassed ?? 0));
2993
+ out.checks = {
2994
+ text: `${passed}/${pr.checksTotal}`,
2995
+ ratio: passed / pr.checksTotal
2996
+ };
2997
+ }
2907
2998
  }
2999
+ return out;
3000
+ };
3001
+ if (c.status === "resolved")
3002
+ return hasProposal || pr ? withPr("reviewed") : null;
3003
+ if (pr)
3004
+ return withPr("in_pr");
3005
+ if (c.status === "in_review")
3006
+ return { stage: "preview", label: LIFECYCLE_LABELS.preview };
3007
+ if (hasProposal)
3008
+ return { stage: "sent", label: LIFECYCLE_LABELS.sent };
3009
+ return null;
3010
+ }
3011
+ function awaitingReview(comments) {
3012
+ return comments.filter((c) => c.status === "in_review");
3013
+ }
3014
+
3015
+ // ../shared/dist/iteration.js
3016
+ function emptyIterations() {
3017
+ return { items: [], index: -1 };
3018
+ }
3019
+ function current(state) {
3020
+ return state.index >= 0 ? state.items[state.index] ?? null : null;
3021
+ }
3022
+ function addIteration(state, iteration) {
3023
+ const head = state.items.slice(0, state.index + 1);
3024
+ const items = [...head, iteration].slice(-MAX_ITERATIONS);
3025
+ return { items, index: items.length - 1 };
3026
+ }
3027
+ var MAX_ITERATIONS = 20;
3028
+ function canUndo(state) {
3029
+ return state.items.length > 0;
3030
+ }
3031
+ function undo(state) {
3032
+ if (!state.items.length)
3033
+ return state;
3034
+ const items = state.items.slice(0, -1);
3035
+ return { items, index: items.length - 1 };
3036
+ }
3037
+ function canMove(state, delta) {
3038
+ const next = state.index + delta;
3039
+ return next >= 0 && next < state.items.length;
3040
+ }
3041
+ function move(state, delta) {
3042
+ return canMove(state, delta) ? { ...state, index: state.index + delta } : state;
3043
+ }
3044
+ function stackLabel(state) {
3045
+ return state.items.length ? `${state.index + 1} / ${state.items.length}` : "";
3046
+ }
3047
+
3048
+ // ../shared/dist/consent.js
3049
+ function emptyConsent() {
3050
+ return { state: "idle", request: null, history: [] };
3051
+ }
3052
+ function isNavigableUrl(raw) {
3053
+ try {
3054
+ const u = new URL(raw);
3055
+ return u.protocol === "http:" || u.protocol === "https:";
3056
+ } catch {
3057
+ return false;
2908
3058
  }
2909
- writeAll(projectKey, url, comments) {
2910
- localStorage.setItem(this.key(projectKey, url), JSON.stringify(comments));
3059
+ }
3060
+ function requestNavigation(record, request) {
3061
+ if (!isNavigableUrl(request.url))
3062
+ return record;
3063
+ return {
3064
+ state: "requested",
3065
+ request: {
3066
+ id: request.id ?? `nav${Date.now().toString(36)}`,
3067
+ at: request.at ?? (/* @__PURE__ */ new Date()).toISOString(),
3068
+ url: request.url,
3069
+ reason: request.reason,
3070
+ requester: request.requester
3071
+ },
3072
+ history: record.history
3073
+ };
3074
+ }
3075
+ function isPending(record) {
3076
+ return record.state === "requested" && record.request !== null;
3077
+ }
3078
+ function decide(record, granted, now = (/* @__PURE__ */ new Date()).toISOString()) {
3079
+ if (!isPending(record))
3080
+ return { record, navigateTo: null };
3081
+ const url = record.request.url;
3082
+ return {
3083
+ record: {
3084
+ state: granted ? "granted" : "denied",
3085
+ request: null,
3086
+ history: [...record.history, { url, decision: granted ? "granted" : "denied", at: now }]
3087
+ },
3088
+ navigateTo: granted ? url : null
3089
+ };
3090
+ }
3091
+ function withdraw(record) {
3092
+ return isPending(record) ? { ...record, state: "idle", request: null } : record;
3093
+ }
3094
+
3095
+ // ../shared/dist/thread.js
3096
+ function firstMessageFromComment(comment) {
3097
+ return {
3098
+ id: `${comment.id}:0`,
3099
+ threadId: comment.id,
3100
+ author: { ...comment.author, type: "user" },
3101
+ body: comment.body,
3102
+ attachments: comment.attachments?.length ? comment.attachments : void 0,
3103
+ createdAt: comment.createdAt
3104
+ };
3105
+ }
3106
+ function threadConversation(comment, replies) {
3107
+ return [firstMessageFromComment(comment), ...replies].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3108
+ }
3109
+ function iterationLabel(link) {
3110
+ if (!link.parentThreadId || link.iterationType !== "revision")
3111
+ return null;
3112
+ return `Iteration ${link.iterationNumber ?? 2}`;
3113
+ }
3114
+
3115
+ // ../shared/dist/timeline.js
3116
+ function describeTarget(input) {
3117
+ const parts = [];
3118
+ if (input.anchor?.tag)
3119
+ parts.push(`a <${input.anchor.tag}>`);
3120
+ if (input.anchor?.selector)
3121
+ parts.push(`\`${input.anchor.selector}\``);
3122
+ return parts.length ? parts.join(" ") : input.kind === "free" ? "a page note" : "an element";
3123
+ }
3124
+ function threadTimeline(input, messages = []) {
3125
+ const out = [];
3126
+ const latest = messages.length ? messages[messages.length - 1].at : input.createdAt;
3127
+ out.push({
3128
+ at: input.createdAt,
3129
+ kind: "captured",
3130
+ label: "Feedback captured",
3131
+ detail: [input.title, describeTarget(input)].filter(Boolean).join(" \xB7 ")
3132
+ });
3133
+ for (const m of messages) {
3134
+ out.push({
3135
+ at: m.at,
3136
+ kind: "message",
3137
+ label: `${m.authorName} replied`,
3138
+ actor: m.fromAgent ? m.authorName : void 0
3139
+ });
2911
3140
  }
2912
- async list(projectKey, url) {
2913
- return this.readAll(projectKey, url);
3141
+ const stage = normalizeStage(input.status);
3142
+ const reached = (s) => STAGE_ORDER.indexOf(stage) >= STAGE_ORDER.indexOf(s);
3143
+ if (input.proposal || reached("in_review")) {
3144
+ const agent = input.proposal?.author ?? "Claude Code";
3145
+ out.push({
3146
+ at: input.proposal?.createdAt ?? latest,
3147
+ kind: "sent",
3148
+ label: `Change ready from ${agent}`,
3149
+ detail: "The rewritten markup is on the thread.",
3150
+ actor: agent
3151
+ });
2914
3152
  }
2915
- /** Every page's comments for this project, newest first — the "All" scope. */
2916
- async listAll(projectKey) {
2917
- const prefix = `loupe:${projectKey}:`;
2918
- const out = [];
2919
- for (let i = 0; i < localStorage.length; i++) {
2920
- const k = localStorage.key(i);
2921
- if (!k || !k.startsWith(prefix)) continue;
2922
- try {
2923
- out.push(...JSON.parse(localStorage.getItem(k) || "[]"));
2924
- } catch {
2925
- }
3153
+ if (input.pr) {
3154
+ out.push({
3155
+ at: latest,
3156
+ kind: "pr",
3157
+ label: input.pr.number ? `Pull request #${input.pr.number} opened` : "Pull request opened",
3158
+ detail: input.pr.state === "merged" ? "Merged." : input.pr.state === "closed" ? "Closed without merging." : "Waiting on review."
3159
+ });
3160
+ if (input.pr.previewUrl) {
3161
+ out.push({ at: latest, kind: "preview", label: "Preview live", detail: input.pr.previewUrl });
2926
3162
  }
2927
- return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2928
3163
  }
2929
- async save(comment) {
2930
- const all = this.readAll(comment.projectKey, comment.url);
3164
+ if (reached("in_review")) {
3165
+ out.push({ at: latest, kind: "review", label: "Waiting on a human review", detail: "Only a person can close this." });
3166
+ }
3167
+ if (stage === "resolved") {
3168
+ out.push({ at: latest, kind: "resolved", label: "Resolved", detail: "A person closed this." });
3169
+ }
3170
+ return out;
3171
+ }
3172
+ var STAGE_ORDER = ["queue", "todo", "in_progress", "in_review", "resolved"];
3173
+ function normalizeStage(status) {
3174
+ if (status === "open")
3175
+ return "queue";
3176
+ if (status === "done")
3177
+ return "resolved";
3178
+ return status;
3179
+ }
3180
+ function threadAsText(input, messages = []) {
3181
+ const lines = [
3182
+ `# ${input.title ?? "Feedback"} (#${input.id})`,
3183
+ "",
3184
+ `- Stage: ${normalizeStage(input.status)}`,
3185
+ `- Page: ${input.url ?? "\u2014"}`
3186
+ ];
3187
+ if (input.anchor?.selector)
3188
+ lines.push(`- Target: \`${input.anchor.selector}\``);
3189
+ if (input.pr?.number)
3190
+ lines.push(`- PR: #${input.pr.number}${input.pr.previewUrl ? ` \xB7 preview ${input.pr.previewUrl}` : ""}`);
3191
+ lines.push("", "## Request", "", input.body);
3192
+ if (messages.length) {
3193
+ lines.push("", "## Conversation", "");
3194
+ for (const m of messages)
3195
+ lines.push(`**${m.authorName}**${m.fromAgent ? " (agent)" : ""} \u2014 ${m.at}`, "", m.body, "");
3196
+ }
3197
+ return lines.join("\n");
3198
+ }
3199
+
3200
+ // ../shared/dist/mentions.js
3201
+ var HANDLE = /^[A-Za-z0-9][A-Za-z0-9._-]*/;
3202
+ function excludedRanges(body) {
3203
+ const ranges = [];
3204
+ for (const m of body.matchAll(/```[\s\S]*?```/g))
3205
+ ranges.push([m.index, m.index + m[0].length]);
3206
+ const fenced = (i) => ranges.some(([a, b]) => i >= a && i < b);
3207
+ for (const m of body.matchAll(/`[^`\n]*`/g)) {
3208
+ if (!fenced(m.index))
3209
+ ranges.push([m.index, m.index + m[0].length]);
3210
+ }
3211
+ return ranges;
3212
+ }
3213
+ function parseMentions(body) {
3214
+ if (!body)
3215
+ return [];
3216
+ const excluded = excludedRanges(body);
3217
+ const inExcluded = (i) => excluded.some(([a, b]) => i >= a && i < b);
3218
+ const out = [];
3219
+ const seen = /* @__PURE__ */ new Set();
3220
+ for (let i = 0; i < body.length; i++) {
3221
+ if (body[i] !== "@")
3222
+ continue;
3223
+ if (inExcluded(i))
3224
+ continue;
3225
+ const prev = i > 0 ? body[i - 1] : "";
3226
+ if (prev && !/[\s([{<"'*_~]/.test(prev))
3227
+ continue;
3228
+ const match = HANDLE.exec(body.slice(i + 1));
3229
+ if (!match)
3230
+ continue;
3231
+ const raw = match[0];
3232
+ const handle = raw.replace(/[._-]+$/, "");
3233
+ if (!handle)
3234
+ continue;
3235
+ const key = handle.toLowerCase();
3236
+ if (seen.has(key))
3237
+ continue;
3238
+ seen.add(key);
3239
+ out.push({ handle, start: i, length: handle.length + 1 });
3240
+ }
3241
+ return out;
3242
+ }
3243
+ function mentionSegments(body, mentions) {
3244
+ if (!mentions.length)
3245
+ return [{ text: body, mention: false }];
3246
+ const sorted = [...mentions].sort((a, b) => a.start - b.start);
3247
+ const out = [];
3248
+ let at = 0;
3249
+ for (const m of sorted) {
3250
+ if (m.start > at)
3251
+ out.push({ text: body.slice(at, m.start), mention: false });
3252
+ out.push({ text: body.slice(m.start, m.start + m.length), mention: true });
3253
+ at = m.start + m.length;
3254
+ }
3255
+ if (at < body.length)
3256
+ out.push({ text: body.slice(at), mention: false });
3257
+ return out;
3258
+ }
3259
+ function mentionSuggestions(body, caret, candidates) {
3260
+ const before = body.slice(0, caret);
3261
+ const at = before.lastIndexOf("@");
3262
+ if (at < 0)
3263
+ return [];
3264
+ const typed = before.slice(at + 1);
3265
+ if (/\s/.test(typed))
3266
+ return [];
3267
+ const q = typed.toLowerCase();
3268
+ return candidates.filter((c) => !q || c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q)).slice(0, 6);
3269
+ }
3270
+
3271
+ // ../shared/dist/needs-you.js
3272
+ var NEEDS_YOU_LABELS = {
3273
+ review: "Waiting on your review",
3274
+ question: "The agent asked you something",
3275
+ failed: "The agent's run failed"
3276
+ };
3277
+ function looksLikeQuestion(body) {
3278
+ const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
3279
+ const last = lines[lines.length - 1];
3280
+ if (!last)
3281
+ return false;
3282
+ return /\?["')\]]*$/.test(last);
3283
+ }
3284
+ function needsYou(input) {
3285
+ const stage = normalizeStage2(input.status);
3286
+ if (stage === "resolved")
3287
+ return { needs: false };
3288
+ if (input.agentFailed)
3289
+ return { needs: true, reason: "failed", label: NEEDS_YOU_LABELS.failed };
3290
+ if (input.last?.fromAgent && looksLikeQuestion(input.last.body)) {
3291
+ return { needs: true, reason: "question", label: NEEDS_YOU_LABELS.question };
3292
+ }
3293
+ if (stage === "in_review")
3294
+ return { needs: true, reason: "review", label: NEEDS_YOU_LABELS.review };
3295
+ return { needs: false };
3296
+ }
3297
+ function normalizeStage2(status) {
3298
+ if (status === "open")
3299
+ return "queue";
3300
+ if (status === "done")
3301
+ return "resolved";
3302
+ return status;
3303
+ }
3304
+
3305
+ // ../shared/dist/reactions.js
3306
+ var REACTION_CHOICES = ["\u{1F44D}", "\u{1F389}", "\u{1F440}", "\u{1F64F}", "\u2764\uFE0F", "\u{1F680}"];
3307
+ function summarizeReactions(reactions, viewerId) {
3308
+ const byEmoji = /* @__PURE__ */ new Map();
3309
+ for (const r of reactions) {
3310
+ const list = byEmoji.get(r.emoji) ?? [];
3311
+ list.push(r);
3312
+ byEmoji.set(r.emoji, list);
3313
+ }
3314
+ const rank = (emoji) => {
3315
+ const at = REACTION_CHOICES.indexOf(emoji);
3316
+ return at < 0 ? REACTION_CHOICES.length : at;
3317
+ };
3318
+ return [...byEmoji.entries()].map(([emoji, list]) => ({
3319
+ emoji,
3320
+ count: list.length,
3321
+ mine: viewerId ? list.some((r) => r.userId === viewerId) : false,
3322
+ users: list.map((r) => r.userName ?? r.userId)
3323
+ })).sort((a, b) => b.count - a.count || rank(a.emoji) - rank(b.emoji) || a.emoji.localeCompare(b.emoji));
3324
+ }
3325
+ function toggleReaction(reactions, next) {
3326
+ const has = reactions.some((r) => r.messageId === next.messageId && r.emoji === next.emoji && r.userId === next.userId);
3327
+ if (has) {
3328
+ return reactions.filter((r) => !(r.messageId === next.messageId && r.emoji === next.emoji && r.userId === next.userId));
3329
+ }
3330
+ return [...reactions, next];
3331
+ }
3332
+
3333
+ // ../shared/dist/presence.js
3334
+ var PEER_HEARTBEAT_MS = 6e3;
3335
+ function initialsOf(name) {
3336
+ const words = name.trim().split(/\s+/).filter(Boolean);
3337
+ if (!words.length)
3338
+ return "?";
3339
+ if (words.length === 1)
3340
+ return words[0].slice(0, 1).toUpperCase();
3341
+ return (words[0].slice(0, 1) + words[words.length - 1].slice(0, 1)).toUpperCase();
3342
+ }
3343
+
3344
+ // ../shared/dist/index.js
3345
+ var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
3346
+ var STAGE_LABELS = {
3347
+ queue: "Queue",
3348
+ todo: "To Do",
3349
+ in_progress: "In Progress",
3350
+ in_review: "In Review",
3351
+ resolved: "Resolved"
3352
+ };
3353
+ var LEGACY_STATUS = {
3354
+ open: "queue",
3355
+ in_progress: "in_progress",
3356
+ done: "resolved"
3357
+ };
3358
+ function normalizeStatus(value) {
3359
+ if (typeof value === "string") {
3360
+ if (COMMENT_STAGES.includes(value))
3361
+ return value;
3362
+ if (value in LEGACY_STATUS)
3363
+ return LEGACY_STATUS[value];
3364
+ }
3365
+ return "queue";
3366
+ }
3367
+ var COMMENT_PRIORITIES = ["critical", "high", "medium", "low"];
3368
+ var PRIORITY_LABELS = {
3369
+ critical: "Critical",
3370
+ high: "High",
3371
+ medium: "Medium",
3372
+ low: "Low"
3373
+ };
3374
+ var CHANGE_TYPES = ["frontend", "backend", "api", "other"];
3375
+ var CHANGE_TYPE_LABELS = {
3376
+ frontend: "Frontend",
3377
+ backend: "Backend",
3378
+ api: "API",
3379
+ other: "Other"
3380
+ };
3381
+ var DEFAULT_PRIORITY = "medium";
3382
+ var DEFAULT_CHANGE_TYPE = "other";
3383
+ function normalizePriority(value) {
3384
+ if (typeof value === "string" && COMMENT_PRIORITIES.includes(value)) {
3385
+ return value;
3386
+ }
3387
+ return DEFAULT_PRIORITY;
3388
+ }
3389
+
3390
+ // src/store.ts
3391
+ var LocalStorageAdapter = class {
3392
+ key(projectKey, url) {
3393
+ return `loupe:${projectKey}:${url}`;
3394
+ }
3395
+ readAll(projectKey, url) {
3396
+ try {
3397
+ const raw = localStorage.getItem(this.key(projectKey, url));
3398
+ return raw ? JSON.parse(raw) : [];
3399
+ } catch {
3400
+ return [];
3401
+ }
3402
+ }
3403
+ writeAll(projectKey, url, comments) {
3404
+ localStorage.setItem(this.key(projectKey, url), JSON.stringify(comments));
3405
+ }
3406
+ async list(projectKey, url) {
3407
+ return this.readAll(projectKey, url);
3408
+ }
3409
+ /** Every page's comments for this project, newest first — the "All" scope. */
3410
+ async listAll(projectKey) {
3411
+ const prefix = `loupe:${projectKey}:`;
3412
+ const out = [];
3413
+ for (let i = 0; i < localStorage.length; i++) {
3414
+ const k = localStorage.key(i);
3415
+ if (!k || !k.startsWith(prefix)) continue;
3416
+ try {
3417
+ out.push(...JSON.parse(localStorage.getItem(k) || "[]"));
3418
+ } catch {
3419
+ }
3420
+ }
3421
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
3422
+ }
3423
+ async save(comment) {
3424
+ const all = this.readAll(comment.projectKey, comment.url);
2931
3425
  all.push(comment);
2932
3426
  this.writeAll(comment.projectKey, comment.url, all);
2933
3427
  return comment;
@@ -2993,6 +3487,27 @@ var LocalStorageAdapter = class {
2993
3487
  localStorage.setItem(this.msgKey(threadId), JSON.stringify(all));
2994
3488
  return stored;
2995
3489
  }
3490
+ /** Offline reactions, keyed per thread. */
3491
+ async listReactions(threadId) {
3492
+ try {
3493
+ const raw = localStorage.getItem(`loupe:rxn:${threadId}`);
3494
+ const parsed = raw ? JSON.parse(raw) : [];
3495
+ return Array.isArray(parsed) ? parsed : [];
3496
+ } catch {
3497
+ return [];
3498
+ }
3499
+ }
3500
+ async toggleReaction(input) {
3501
+ const current2 = await this.listReactions(input.threadId);
3502
+ const next = toggleReaction(current2, {
3503
+ messageId: input.messageId,
3504
+ emoji: input.emoji,
3505
+ userId: input.userId,
3506
+ userName: input.userName
3507
+ });
3508
+ localStorage.setItem(`loupe:rxn:${input.threadId}`, JSON.stringify(next));
3509
+ return next;
3510
+ }
2996
3511
  /** Offline: the people are whoever has already commented locally. */
2997
3512
  async listPeople(projectKey) {
2998
3513
  const prefix = `loupe:${projectKey}:`;
@@ -3114,471 +3629,71 @@ var HttpAdapter = class {
3114
3629
  return await res.json();
3115
3630
  }
3116
3631
  async addMessage(threadId, message) {
3117
- const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(threadId)}/messages`, this.opts({
3118
- method: "POST",
3119
- headers: this.headers(),
3120
- body: JSON.stringify(message)
3121
- }));
3122
- if (!res.ok) throw new Error(`addMessage failed: ${res.status}`);
3123
- return await res.json();
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
- }
3145
- async update(id, patch) {
3146
- const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3147
- method: "PATCH",
3148
- headers: this.headers(),
3149
- body: JSON.stringify(patch)
3150
- }));
3151
- if (!res.ok) throw new Error(`update failed: ${res.status}`);
3152
- }
3153
- async remove(id) {
3154
- const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3155
- method: "DELETE",
3156
- headers: this.headers()
3157
- }));
3158
- if (!res.ok && res.status !== 404) throw new Error(`remove failed: ${res.status}`);
3159
- }
3160
- };
3161
-
3162
- // ../shared/dist/activity.js
3163
- var ACTIVITY_STATUS_LABELS = {
3164
- idle: "Idle",
3165
- working: "Working",
3166
- error: "Error"
3167
- };
3168
- function formatDuration(ms) {
3169
- if (!ms || ms < 0)
3170
- return "\u2014";
3171
- const s = Math.round(ms / 1e3);
3172
- if (s < 60)
3173
- return `${s}s`;
3174
- const m = Math.floor(s / 60);
3175
- const rest = s % 60;
3176
- if (m < 60)
3177
- return rest ? `${m}m ${rest}s` : `${m}m`;
3178
- const h = Math.floor(m / 60);
3179
- return `${h}h ${m % 60}m`;
3180
- }
3181
- function summarizeActivity(events, status = "idle", now = Date.now()) {
3182
- const files = /* @__PURE__ */ new Set();
3183
- const kinds = /* @__PURE__ */ new Map();
3184
- let errors = 0;
3185
- let first = Infinity;
3186
- for (const e of events) {
3187
- for (const f of e.files ?? [])
3188
- files.add(f);
3189
- kinds.set(e.kind, (kinds.get(e.kind) ?? 0) + 1);
3190
- if (e.level === "error")
3191
- errors++;
3192
- const t = Date.parse(e.at);
3193
- if (!Number.isNaN(t))
3194
- first = Math.min(first, t);
3195
- }
3196
- return {
3197
- status,
3198
- events: events.length,
3199
- errors,
3200
- files: files.size,
3201
- durationMs: events.length && first !== Infinity ? Math.max(0, now - first) : 0,
3202
- byKind: [...kinds.entries()].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind))
3203
- };
3204
- }
3205
-
3206
- // ../shared/dist/lifecycle.js
3207
- var LIFECYCLE_LABELS = {
3208
- sent: "Sent to agent",
3209
- in_pr: "In PR",
3210
- preview: "Review preview",
3211
- reviewed: "Reviewed"
3212
- };
3213
- function lifecycle(c) {
3214
- const hasProposal = !!c.proposal;
3215
- const pr = c.pr ?? void 0;
3216
- const withPr = (stage) => {
3217
- const out = { stage, label: LIFECYCLE_LABELS[stage] };
3218
- if (pr) {
3219
- out.pr = pr;
3220
- if (typeof pr.checksTotal === "number" && pr.checksTotal > 0) {
3221
- const passed = Math.max(0, Math.min(pr.checksTotal, pr.checksPassed ?? 0));
3222
- out.checks = {
3223
- text: `${passed}/${pr.checksTotal}`,
3224
- ratio: passed / pr.checksTotal
3225
- };
3226
- }
3227
- }
3228
- return out;
3229
- };
3230
- if (c.status === "resolved")
3231
- return hasProposal || pr ? withPr("reviewed") : null;
3232
- if (pr)
3233
- return withPr("in_pr");
3234
- if (c.status === "in_review")
3235
- return { stage: "preview", label: LIFECYCLE_LABELS.preview };
3236
- if (hasProposal)
3237
- return { stage: "sent", label: LIFECYCLE_LABELS.sent };
3238
- return null;
3239
- }
3240
- function awaitingReview(comments) {
3241
- return comments.filter((c) => c.status === "in_review");
3242
- }
3243
-
3244
- // ../shared/dist/iteration.js
3245
- function emptyIterations() {
3246
- return { items: [], index: -1 };
3247
- }
3248
- function current(state) {
3249
- return state.index >= 0 ? state.items[state.index] ?? null : null;
3250
- }
3251
- function addIteration(state, iteration) {
3252
- const head = state.items.slice(0, state.index + 1);
3253
- const items = [...head, iteration].slice(-MAX_ITERATIONS);
3254
- return { items, index: items.length - 1 };
3255
- }
3256
- var MAX_ITERATIONS = 20;
3257
- function canUndo(state) {
3258
- return state.items.length > 0;
3259
- }
3260
- function undo(state) {
3261
- if (!state.items.length)
3262
- return state;
3263
- const items = state.items.slice(0, -1);
3264
- return { items, index: items.length - 1 };
3265
- }
3266
- function canMove(state, delta) {
3267
- const next = state.index + delta;
3268
- return next >= 0 && next < state.items.length;
3269
- }
3270
- function move(state, delta) {
3271
- return canMove(state, delta) ? { ...state, index: state.index + delta } : state;
3272
- }
3273
- function stackLabel(state) {
3274
- return state.items.length ? `${state.index + 1} / ${state.items.length}` : "";
3275
- }
3276
-
3277
- // ../shared/dist/consent.js
3278
- function emptyConsent() {
3279
- return { state: "idle", request: null, history: [] };
3280
- }
3281
- function isNavigableUrl(raw) {
3282
- try {
3283
- const u = new URL(raw);
3284
- return u.protocol === "http:" || u.protocol === "https:";
3285
- } catch {
3286
- return false;
3287
- }
3288
- }
3289
- function requestNavigation(record, request) {
3290
- if (!isNavigableUrl(request.url))
3291
- return record;
3292
- return {
3293
- state: "requested",
3294
- request: {
3295
- id: request.id ?? `nav${Date.now().toString(36)}`,
3296
- at: request.at ?? (/* @__PURE__ */ new Date()).toISOString(),
3297
- url: request.url,
3298
- reason: request.reason,
3299
- requester: request.requester
3300
- },
3301
- history: record.history
3302
- };
3303
- }
3304
- function isPending(record) {
3305
- return record.state === "requested" && record.request !== null;
3306
- }
3307
- function decide(record, granted, now = (/* @__PURE__ */ new Date()).toISOString()) {
3308
- if (!isPending(record))
3309
- return { record, navigateTo: null };
3310
- const url = record.request.url;
3311
- return {
3312
- record: {
3313
- state: granted ? "granted" : "denied",
3314
- request: null,
3315
- history: [...record.history, { url, decision: granted ? "granted" : "denied", at: now }]
3316
- },
3317
- navigateTo: granted ? url : null
3318
- };
3319
- }
3320
- function withdraw(record) {
3321
- return isPending(record) ? { ...record, state: "idle", request: null } : record;
3322
- }
3323
-
3324
- // ../shared/dist/thread.js
3325
- function firstMessageFromComment(comment) {
3326
- return {
3327
- id: `${comment.id}:0`,
3328
- threadId: comment.id,
3329
- author: { ...comment.author, type: "user" },
3330
- body: comment.body,
3331
- attachments: comment.attachments?.length ? comment.attachments : void 0,
3332
- createdAt: comment.createdAt
3333
- };
3334
- }
3335
- function threadConversation(comment, replies) {
3336
- return [firstMessageFromComment(comment), ...replies].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3337
- }
3338
- function iterationLabel(link) {
3339
- if (!link.parentThreadId || link.iterationType !== "revision")
3340
- return null;
3341
- return `Iteration ${link.iterationNumber ?? 2}`;
3342
- }
3343
-
3344
- // ../shared/dist/timeline.js
3345
- function describeTarget(input) {
3346
- const parts = [];
3347
- if (input.anchor?.tag)
3348
- parts.push(`a <${input.anchor.tag}>`);
3349
- if (input.anchor?.selector)
3350
- parts.push(`\`${input.anchor.selector}\``);
3351
- return parts.length ? parts.join(" ") : input.kind === "free" ? "a page note" : "an element";
3352
- }
3353
- function threadTimeline(input, messages = []) {
3354
- const out = [];
3355
- const latest = messages.length ? messages[messages.length - 1].at : input.createdAt;
3356
- out.push({
3357
- at: input.createdAt,
3358
- kind: "captured",
3359
- label: "Feedback captured",
3360
- detail: [input.title, describeTarget(input)].filter(Boolean).join(" \xB7 ")
3361
- });
3362
- for (const m of messages) {
3363
- out.push({
3364
- at: m.at,
3365
- kind: "message",
3366
- label: `${m.authorName} replied`,
3367
- actor: m.fromAgent ? m.authorName : void 0
3368
- });
3369
- }
3370
- const stage = normalizeStage(input.status);
3371
- const reached = (s) => STAGE_ORDER.indexOf(stage) >= STAGE_ORDER.indexOf(s);
3372
- if (input.proposal || reached("in_review")) {
3373
- const agent = input.proposal?.author ?? "Claude Code";
3374
- out.push({
3375
- at: input.proposal?.createdAt ?? latest,
3376
- kind: "sent",
3377
- label: `Change ready from ${agent}`,
3378
- detail: "The rewritten markup is on the thread.",
3379
- actor: agent
3380
- });
3381
- }
3382
- if (input.pr) {
3383
- out.push({
3384
- at: latest,
3385
- kind: "pr",
3386
- label: input.pr.number ? `Pull request #${input.pr.number} opened` : "Pull request opened",
3387
- detail: input.pr.state === "merged" ? "Merged." : input.pr.state === "closed" ? "Closed without merging." : "Waiting on review."
3388
- });
3389
- if (input.pr.previewUrl) {
3390
- out.push({ at: latest, kind: "preview", label: "Preview live", detail: input.pr.previewUrl });
3391
- }
3392
- }
3393
- if (reached("in_review")) {
3394
- out.push({ at: latest, kind: "review", label: "Waiting on a human review", detail: "Only a person can close this." });
3632
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(threadId)}/messages`, this.opts({
3633
+ method: "POST",
3634
+ headers: this.headers(),
3635
+ body: JSON.stringify(message)
3636
+ }));
3637
+ if (!res.ok) throw new Error(`addMessage failed: ${res.status}`);
3638
+ return await res.json();
3395
3639
  }
3396
- if (stage === "resolved") {
3397
- out.push({ at: latest, kind: "resolved", label: "Resolved", detail: "A person closed this." });
3640
+ async listReactions(threadId) {
3641
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(threadId)}/messages/all/reactions`, this.opts({ headers: this.headers() }));
3642
+ if (res.status === 404) return [];
3643
+ if (!res.ok) throw new Error(`listReactions failed: ${res.status}`);
3644
+ return (await res.json()).reactions;
3398
3645
  }
3399
- return out;
3400
- }
3401
- var STAGE_ORDER = ["queue", "todo", "in_progress", "in_review", "resolved"];
3402
- function normalizeStage(status) {
3403
- if (status === "open")
3404
- return "queue";
3405
- if (status === "done")
3406
- return "resolved";
3407
- return status;
3408
- }
3409
- function threadAsText(input, messages = []) {
3410
- const lines = [
3411
- `# ${input.title ?? "Feedback"} (#${input.id})`,
3412
- "",
3413
- `- Stage: ${normalizeStage(input.status)}`,
3414
- `- Page: ${input.url ?? "\u2014"}`
3415
- ];
3416
- if (input.anchor?.selector)
3417
- lines.push(`- Target: \`${input.anchor.selector}\``);
3418
- if (input.pr?.number)
3419
- lines.push(`- PR: #${input.pr.number}${input.pr.previewUrl ? ` \xB7 preview ${input.pr.previewUrl}` : ""}`);
3420
- lines.push("", "## Request", "", input.body);
3421
- if (messages.length) {
3422
- lines.push("", "## Conversation", "");
3423
- for (const m of messages)
3424
- lines.push(`**${m.authorName}**${m.fromAgent ? " (agent)" : ""} \u2014 ${m.at}`, "", m.body, "");
3646
+ async toggleReaction(input) {
3647
+ const res = await fetch(
3648
+ `${this.base}/v1/comments/${encodeURIComponent(input.threadId)}/messages/${encodeURIComponent(input.messageId)}/reactions`,
3649
+ this.opts({
3650
+ method: "POST",
3651
+ headers: this.headers(),
3652
+ body: JSON.stringify({ emoji: input.emoji, userId: input.userId, userName: input.userName })
3653
+ })
3654
+ );
3655
+ if (!res.ok) throw new Error(`toggleReaction failed: ${res.status}`);
3656
+ return (await res.json()).reactions;
3425
3657
  }
3426
- return lines.join("\n");
3427
- }
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]);
3658
+ /** Everyone who has taken part in this project, for mention resolution. */
3659
+ async listPeople(projectKey) {
3660
+ const q = new URLSearchParams({ projectKey });
3661
+ const res = await fetch(`${this.base}/v1/people?${q}`, this.opts({ headers: this.headers() }));
3662
+ if (!res.ok) throw new Error(`listPeople failed: ${res.status}`);
3663
+ return await res.json();
3439
3664
  }
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 });
3665
+ async listNotifications(projectKey, recipient) {
3666
+ const q = new URLSearchParams({ projectKey, recipient });
3667
+ const res = await fetch(`${this.base}/v1/notifications?${q}`, this.opts({ headers: this.headers() }));
3668
+ if (!res.ok) throw new Error(`listNotifications failed: ${res.status}`);
3669
+ return (await res.json()).notifications;
3469
3670
  }
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;
3671
+ async markNotificationsRead(projectKey, recipient, id) {
3672
+ await fetch(`${this.base}/v1/notifications/read`, this.opts({
3673
+ method: "POST",
3674
+ headers: this.headers(),
3675
+ body: JSON.stringify({ projectKey, recipient, id })
3676
+ }));
3483
3677
  }
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 };
3678
+ async update(id, patch) {
3679
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3680
+ method: "PATCH",
3681
+ headers: this.headers(),
3682
+ body: JSON.stringify(patch)
3683
+ }));
3684
+ if (!res.ok) throw new Error(`update failed: ${res.status}`);
3521
3685
  }
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
-
3534
- // ../shared/dist/index.js
3535
- var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
3536
- var STAGE_LABELS = {
3537
- queue: "Queue",
3538
- todo: "To Do",
3539
- in_progress: "In Progress",
3540
- in_review: "In Review",
3541
- resolved: "Resolved"
3542
- };
3543
- var LEGACY_STATUS = {
3544
- open: "queue",
3545
- in_progress: "in_progress",
3546
- done: "resolved"
3547
- };
3548
- function normalizeStatus(value) {
3549
- if (typeof value === "string") {
3550
- if (COMMENT_STAGES.includes(value))
3551
- return value;
3552
- if (value in LEGACY_STATUS)
3553
- return LEGACY_STATUS[value];
3686
+ async remove(id) {
3687
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3688
+ method: "DELETE",
3689
+ headers: this.headers()
3690
+ }));
3691
+ if (!res.ok && res.status !== 404) throw new Error(`remove failed: ${res.status}`);
3554
3692
  }
3555
- return "queue";
3556
- }
3557
- var COMMENT_PRIORITIES = ["critical", "high", "medium", "low"];
3558
- var PRIORITY_LABELS = {
3559
- critical: "Critical",
3560
- high: "High",
3561
- medium: "Medium",
3562
- low: "Low"
3563
- };
3564
- var CHANGE_TYPES = ["frontend", "backend", "api", "other"];
3565
- var CHANGE_TYPE_LABELS = {
3566
- frontend: "Frontend",
3567
- backend: "Backend",
3568
- api: "API",
3569
- other: "Other"
3570
3693
  };
3571
- var DEFAULT_PRIORITY = "medium";
3572
- var DEFAULT_CHANGE_TYPE = "other";
3573
- function normalizePriority(value) {
3574
- if (typeof value === "string" && COMMENT_PRIORITIES.includes(value)) {
3575
- return value;
3576
- }
3577
- return DEFAULT_PRIORITY;
3578
- }
3579
3694
 
3580
3695
  // src/app.ts
3581
- var SDK_VERSION = true ? "0.10.23" : "dev";
3696
+ var SDK_VERSION = true ? "0.10.24" : "dev";
3582
3697
  var ACCENTS = [
3583
3698
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3584
3699
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -3996,6 +4111,10 @@ var LoupeApp = class {
3996
4111
  /** Optimistic replies the store rejected — offered for retry rather than lost. */
3997
4112
  this.msgFailed = /* @__PURE__ */ new Set();
3998
4113
  this.msgErr = /* @__PURE__ */ new Map();
4114
+ /** Other people on this page. Empty unless a bridge is configured. */
4115
+ this.peers = [];
4116
+ /** Reactions per thread, keyed `${threadId}:${messageId}`, so a re-render is free. */
4117
+ this.reactions = /* @__PURE__ */ new Map();
3999
4118
  /** Everyone who has taken part, for mention autocomplete. Fetched once. */
4000
4119
  this.people = /* @__PURE__ */ new Map();
4001
4120
  this.cfg = cfg;
@@ -4015,6 +4134,7 @@ var LoupeApp = class {
4015
4134
  this.renderPins();
4016
4135
  this.renderList();
4017
4136
  this.renderHome();
4137
+ this.startPresence();
4018
4138
  void this.loadNotifications();
4019
4139
  if (this.scope === "all") void this.loadAllComments();
4020
4140
  this.observe();
@@ -4164,7 +4284,9 @@ var LoupeApp = class {
4164
4284
  closeBtn.innerHTML = I_CLOSE;
4165
4285
  closeBtn.onclick = () => this.closeDock();
4166
4286
  ctl.append(posWrap, this.themeBtn, setWrap, minBtn, closeBtn);
4167
- head.append(brand, ctl);
4287
+ this.peerEl = el("div", "peers");
4288
+ if (!this.cfg.bridge) this.peerEl.style.display = "none";
4289
+ head.append(brand, this.peerEl, ctl);
4168
4290
  this.minBar = el("div", "minbar");
4169
4291
  this.minBar.onclick = () => this.setMinimized(false);
4170
4292
  this.tabList = [...BUILTIN_TABS, ...(this.cfg.tabs ?? []).map((t) => ({ id: t.id, label: t.label }))];
@@ -6324,6 +6446,37 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6324
6446
  else body.appendChild(document.createTextNode(seg.text));
6325
6447
  }
6326
6448
  row.append(head, body);
6449
+ const pills = el("div", "rxns");
6450
+ for (const r of summarizeReactions(this.reactionsOf(c.id, m.id), this.cfg.user.id)) {
6451
+ const pill = el("button", "rxn" + (r.mine ? " mine" : ""));
6452
+ pill.append(document.createTextNode(r.emoji), el("span", "rxn-n", String(r.count)));
6453
+ pill.title = r.users.join(", ");
6454
+ pill.onclick = (e) => {
6455
+ e.stopPropagation();
6456
+ void this.react(c, m.id, r.emoji);
6457
+ };
6458
+ pills.appendChild(pill);
6459
+ }
6460
+ const add = el("button", "rxn-add", "\uFF0B");
6461
+ add.title = "Add a reaction";
6462
+ add.onclick = (e) => {
6463
+ e.stopPropagation();
6464
+ const open = pills.querySelector(".rxn-pick");
6465
+ pills.querySelectorAll(".rxn-pick").forEach((n) => n.remove());
6466
+ if (open) return;
6467
+ const pick = el("div", "rxn-pick");
6468
+ for (const emoji of REACTION_CHOICES) {
6469
+ const b = el("button", "rxn-opt", emoji);
6470
+ b.onclick = (ev) => {
6471
+ ev.stopPropagation();
6472
+ void this.react(c, m.id, emoji);
6473
+ };
6474
+ pick.appendChild(b);
6475
+ }
6476
+ pills.appendChild(pick);
6477
+ };
6478
+ pills.appendChild(add);
6479
+ row.appendChild(pills);
6327
6480
  if (this.msgPending.has(m.id)) row.appendChild(el("div", "msg-state", "Sending\u2026"));
6328
6481
  if (this.msgFailed.has(m.id)) {
6329
6482
  const failed = el("div", "msg-state failed");
@@ -6435,6 +6588,108 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6435
6588
  wrap.appendChild(copyRow);
6436
6589
  return wrap;
6437
6590
  }
6591
+ /**
6592
+ * Join, then keep talking.
6593
+ *
6594
+ * Presence is a heartbeat, not a flag, because a browser that is closed or crashes
6595
+ * sends no goodbye. The bridge expires anything silent past the TTL, and the panel
6596
+ * re-joins on a 404 rather than assuming it is still known.
6597
+ */
6598
+ startPresence() {
6599
+ const bridge = this.cfg.bridge;
6600
+ if (!bridge) return;
6601
+ const base = bridge.replace(/\/$/, "");
6602
+ const pageUrl = location.pathname + location.search;
6603
+ const tick = async () => {
6604
+ try {
6605
+ if (!this.peerId) {
6606
+ const res = await fetch(`${base}/presence`, {
6607
+ method: "POST",
6608
+ headers: { "content-type": "application/json" },
6609
+ body: JSON.stringify({ url: pageUrl, userId: this.cfg.user.id, name: this.cfg.user.name })
6610
+ });
6611
+ if (!res.ok) return;
6612
+ const data = await res.json();
6613
+ this.peerId = data.peer?.id;
6614
+ } else {
6615
+ const res = await fetch(`${base}/presence/${encodeURIComponent(this.peerId)}/heartbeat`, { method: "POST" });
6616
+ if (res.status === 404) this.peerId = void 0;
6617
+ }
6618
+ const listed = await fetch(
6619
+ `${base}/presence?url=${encodeURIComponent(pageUrl)}&viewer=${encodeURIComponent(this.peerId ?? "")}`
6620
+ );
6621
+ if (listed.ok) {
6622
+ const data = await listed.json();
6623
+ this.peers = Array.isArray(data.peers) ? data.peers : [];
6624
+ this.renderPeers();
6625
+ }
6626
+ } catch {
6627
+ this.peers = [];
6628
+ this.renderPeers();
6629
+ }
6630
+ };
6631
+ void tick();
6632
+ this.peerTimer = setInterval(() => void tick(), PEER_HEARTBEAT_MS);
6633
+ }
6634
+ renderPeers() {
6635
+ if (!this.peerEl) return;
6636
+ this.peerEl.textContent = "";
6637
+ if (!this.peers.length) {
6638
+ this.peerEl.style.display = "none";
6639
+ return;
6640
+ }
6641
+ this.peerEl.style.display = "";
6642
+ for (const p of this.peers.slice(0, 4)) {
6643
+ const av = el("span", "peer-av", initialsOf(p.name));
6644
+ av.title = `${p.name} is on this page`;
6645
+ this.peerEl.appendChild(av);
6646
+ }
6647
+ if (this.peers.length > 4) this.peerEl.appendChild(el("span", "peer-more", `+${this.peers.length - 4}`));
6648
+ }
6649
+ stopPresence() {
6650
+ if (this.peerTimer) clearInterval(this.peerTimer);
6651
+ this.peerTimer = void 0;
6652
+ if (this.cfg.bridge && this.peerId) {
6653
+ const base = this.cfg.bridge.replace(/\/$/, "");
6654
+ void fetch(`${base}/presence/${encodeURIComponent(this.peerId)}`, { method: "DELETE", keepalive: true }).catch(() => {
6655
+ });
6656
+ }
6657
+ this.peerId = void 0;
6658
+ this.peers = [];
6659
+ }
6660
+ reactionsOf(threadId, messageId) {
6661
+ return this.reactions.get(`${threadId}:${messageId}`) ?? [];
6662
+ }
6663
+ /**
6664
+ * Toggle a reaction.
6665
+ *
6666
+ * Optimistic, then replaced by the server's own set: the server returns the whole
6667
+ * list precisely so the count can never drift from what was stored.
6668
+ */
6669
+ async react(c, messageId, emoji) {
6670
+ const key = `${c.id}:${messageId}`;
6671
+ const before = this.reactionsOf(c.id, messageId);
6672
+ this.reactions.set(key, toggleReaction(before, {
6673
+ messageId,
6674
+ emoji,
6675
+ userId: this.cfg.user.id,
6676
+ userName: this.cfg.user.name
6677
+ }));
6678
+ this.renderList();
6679
+ try {
6680
+ const next = await this.store.toggleReaction({
6681
+ threadId: c.id,
6682
+ messageId,
6683
+ emoji,
6684
+ userId: this.cfg.user.id,
6685
+ userName: this.cfg.user.name
6686
+ });
6687
+ this.reactions.set(key, Array.isArray(next) ? next : this.reactionsOf(c.id, messageId));
6688
+ } catch {
6689
+ this.reactions.set(key, before);
6690
+ }
6691
+ this.renderList();
6692
+ }
6438
6693
  async loadPeople() {
6439
6694
  if (this.people.has(this.cfg.projectKey)) return;
6440
6695
  try {
@@ -6447,6 +6702,17 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6447
6702
  async loadMessages(c) {
6448
6703
  if (this.messages.has(c.id)) return;
6449
6704
  void this.loadPeople();
6705
+ void this.store.listReactions(c.id).then((all) => {
6706
+ if (!Array.isArray(all)) return;
6707
+ for (const r of all) {
6708
+ const key = `${c.id}:${r.messageId}`;
6709
+ const list = this.reactions.get(key) ?? [];
6710
+ if (!list.some((x) => x.userId === r.userId && x.emoji === r.emoji)) list.push(r);
6711
+ this.reactions.set(key, list);
6712
+ }
6713
+ this.renderList();
6714
+ }).catch(() => {
6715
+ });
6450
6716
  try {
6451
6717
  this.messages.set(c.id, await this.store.listMessages(c.id));
6452
6718
  } catch {
@@ -6593,6 +6859,7 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6593
6859
  if (elx) elx.scrollIntoView({ behavior: "smooth", block: "center" });
6594
6860
  }
6595
6861
  destroy() {
6862
+ this.stopPresence();
6596
6863
  this.stopRecording?.();
6597
6864
  this.setMode("off");
6598
6865
  this.mo?.disconnect();