@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.
@@ -794,6 +794,43 @@ 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
+ /* reactions */
798
+ .rxns { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 6px; position: relative; }
799
+ .rxn {
800
+ display: inline-flex; align-items: center; gap: 3px; padding: 1px 7px;
801
+ border: 1px solid var(--line); border-radius: 999px; background: var(--bg);
802
+ color: var(--ink); font-size: 11px; cursor: pointer; line-height: 1.7;
803
+ }
804
+ .rxn:hover { border-color: var(--accent); }
805
+ .rxn.mine { border-color: var(--accent); background: var(--accent-soft); }
806
+ .rxn-n { color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; }
807
+ .rxn.mine .rxn-n { color: var(--accent); }
808
+ .rxn-add {
809
+ width: 20px; height: 20px; padding: 0; border: 1px dashed var(--line); border-radius: 50%;
810
+ background: transparent; color: var(--muted); font-size: 11px; cursor: pointer; line-height: 1;
811
+ }
812
+ .rxn-add:hover { border-color: var(--accent); color: var(--accent); }
813
+ .rxn-pick {
814
+ display: flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 999px;
815
+ background: var(--bg); box-shadow: 0 4px 14px rgb(0 0 0 / 18%); z-index: 3;
816
+ }
817
+ .rxn-opt {
818
+ width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%;
819
+ background: transparent; font-size: 14px; cursor: pointer; line-height: 1;
820
+ }
821
+ .rxn-opt:hover { background: var(--accent-soft); }
822
+
823
+ /* who else is here */
824
+ .peers { display: flex; align-items: center; gap: -2px; margin-right: 8px; }
825
+ .peer-av {
826
+ display: inline-flex; align-items: center; justify-content: center;
827
+ width: 20px; height: 20px; margin-left: -5px; border-radius: 50%;
828
+ border: 2px solid var(--bg); background: var(--accent); color: #fff;
829
+ font-size: 9px; font-weight: 700; letter-spacing: .02em;
830
+ }
831
+ .peer-av:first-child { margin-left: 0; }
832
+ .peer-more { margin-left: 3px; font-size: 10px; color: var(--muted); }
833
+
797
834
  /* in-app mentions */
798
835
  .hnotif { margin-top: 10px; }
799
836
  .nf-head { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; font-size: 11.5px; color: var(--ink); }
@@ -2927,171 +2964,649 @@ var Loupe = (() => {
2927
2964
  });
2928
2965
  }
2929
2966
 
2930
- // src/store.ts
2931
- var LocalStorageAdapter = class {
2932
- key(projectKey, url) {
2933
- return `loupe:${projectKey}:${url}`;
2934
- }
2935
- readAll(projectKey, url) {
2936
- try {
2937
- const raw = localStorage.getItem(this.key(projectKey, url));
2938
- return raw ? JSON.parse(raw) : [];
2939
- } catch {
2940
- return [];
2941
- }
2942
- }
2943
- writeAll(projectKey, url, comments) {
2944
- localStorage.setItem(this.key(projectKey, url), JSON.stringify(comments));
2945
- }
2946
- async list(projectKey, url) {
2947
- return this.readAll(projectKey, url);
2967
+ // ../shared/dist/activity.js
2968
+ var ACTIVITY_STATUS_LABELS = {
2969
+ idle: "Idle",
2970
+ working: "Working",
2971
+ error: "Error"
2972
+ };
2973
+ function formatDuration(ms) {
2974
+ if (!ms || ms < 0)
2975
+ return "\u2014";
2976
+ const s = Math.round(ms / 1e3);
2977
+ if (s < 60)
2978
+ return `${s}s`;
2979
+ const m = Math.floor(s / 60);
2980
+ const rest = s % 60;
2981
+ if (m < 60)
2982
+ return rest ? `${m}m ${rest}s` : `${m}m`;
2983
+ const h = Math.floor(m / 60);
2984
+ return `${h}h ${m % 60}m`;
2985
+ }
2986
+ function summarizeActivity(events, status = "idle", now = Date.now()) {
2987
+ const files = /* @__PURE__ */ new Set();
2988
+ const kinds = /* @__PURE__ */ new Map();
2989
+ let errors = 0;
2990
+ let first = Infinity;
2991
+ for (const e of events) {
2992
+ for (const f of e.files ?? [])
2993
+ files.add(f);
2994
+ kinds.set(e.kind, (kinds.get(e.kind) ?? 0) + 1);
2995
+ if (e.level === "error")
2996
+ errors++;
2997
+ const t = Date.parse(e.at);
2998
+ if (!Number.isNaN(t))
2999
+ first = Math.min(first, t);
2948
3000
  }
2949
- /** Every page's comments for this project, newest first — the "All" scope. */
2950
- async listAll(projectKey) {
2951
- const prefix = `loupe:${projectKey}:`;
2952
- const out = [];
2953
- for (let i = 0; i < localStorage.length; i++) {
2954
- const k = localStorage.key(i);
2955
- if (!k || !k.startsWith(prefix)) continue;
2956
- try {
2957
- out.push(...JSON.parse(localStorage.getItem(k) || "[]"));
2958
- } catch {
3001
+ return {
3002
+ status,
3003
+ events: events.length,
3004
+ errors,
3005
+ files: files.size,
3006
+ durationMs: events.length && first !== Infinity ? Math.max(0, now - first) : 0,
3007
+ byKind: [...kinds.entries()].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind))
3008
+ };
3009
+ }
3010
+
3011
+ // ../shared/dist/lifecycle.js
3012
+ var LIFECYCLE_LABELS = {
3013
+ sent: "Sent to agent",
3014
+ in_pr: "In PR",
3015
+ preview: "Review preview",
3016
+ reviewed: "Reviewed"
3017
+ };
3018
+ function lifecycle(c) {
3019
+ const hasProposal = !!c.proposal;
3020
+ const pr = c.pr ?? void 0;
3021
+ const withPr = (stage) => {
3022
+ const out = { stage, label: LIFECYCLE_LABELS[stage] };
3023
+ if (pr) {
3024
+ out.pr = pr;
3025
+ if (typeof pr.checksTotal === "number" && pr.checksTotal > 0) {
3026
+ const passed = Math.max(0, Math.min(pr.checksTotal, pr.checksPassed ?? 0));
3027
+ out.checks = {
3028
+ text: `${passed}/${pr.checksTotal}`,
3029
+ ratio: passed / pr.checksTotal
3030
+ };
2959
3031
  }
2960
3032
  }
2961
- return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
3033
+ return out;
3034
+ };
3035
+ if (c.status === "resolved")
3036
+ return hasProposal || pr ? withPr("reviewed") : null;
3037
+ if (pr)
3038
+ return withPr("in_pr");
3039
+ if (c.status === "in_review")
3040
+ return { stage: "preview", label: LIFECYCLE_LABELS.preview };
3041
+ if (hasProposal)
3042
+ return { stage: "sent", label: LIFECYCLE_LABELS.sent };
3043
+ return null;
3044
+ }
3045
+ function awaitingReview(comments) {
3046
+ return comments.filter((c) => c.status === "in_review");
3047
+ }
3048
+
3049
+ // ../shared/dist/iteration.js
3050
+ function emptyIterations() {
3051
+ return { items: [], index: -1 };
3052
+ }
3053
+ function current(state) {
3054
+ return state.index >= 0 ? state.items[state.index] ?? null : null;
3055
+ }
3056
+ function addIteration(state, iteration) {
3057
+ const head = state.items.slice(0, state.index + 1);
3058
+ const items = [...head, iteration].slice(-MAX_ITERATIONS);
3059
+ return { items, index: items.length - 1 };
3060
+ }
3061
+ var MAX_ITERATIONS = 20;
3062
+ function canUndo(state) {
3063
+ return state.items.length > 0;
3064
+ }
3065
+ function undo(state) {
3066
+ if (!state.items.length)
3067
+ return state;
3068
+ const items = state.items.slice(0, -1);
3069
+ return { items, index: items.length - 1 };
3070
+ }
3071
+ function canMove(state, delta) {
3072
+ const next = state.index + delta;
3073
+ return next >= 0 && next < state.items.length;
3074
+ }
3075
+ function move(state, delta) {
3076
+ return canMove(state, delta) ? { ...state, index: state.index + delta } : state;
3077
+ }
3078
+ function stackLabel(state) {
3079
+ return state.items.length ? `${state.index + 1} / ${state.items.length}` : "";
3080
+ }
3081
+
3082
+ // ../shared/dist/consent.js
3083
+ function emptyConsent() {
3084
+ return { state: "idle", request: null, history: [] };
3085
+ }
3086
+ function isNavigableUrl(raw) {
3087
+ try {
3088
+ const u = new URL(raw);
3089
+ return u.protocol === "http:" || u.protocol === "https:";
3090
+ } catch {
3091
+ return false;
2962
3092
  }
2963
- async save(comment) {
2964
- const all = this.readAll(comment.projectKey, comment.url);
2965
- all.push(comment);
2966
- this.writeAll(comment.projectKey, comment.url, all);
2967
- return comment;
3093
+ }
3094
+ function requestNavigation(record, request) {
3095
+ if (!isNavigableUrl(request.url))
3096
+ return record;
3097
+ return {
3098
+ state: "requested",
3099
+ request: {
3100
+ id: request.id ?? `nav${Date.now().toString(36)}`,
3101
+ at: request.at ?? (/* @__PURE__ */ new Date()).toISOString(),
3102
+ url: request.url,
3103
+ reason: request.reason,
3104
+ requester: request.requester
3105
+ },
3106
+ history: record.history
3107
+ };
3108
+ }
3109
+ function isPending(record) {
3110
+ return record.state === "requested" && record.request !== null;
3111
+ }
3112
+ function decide(record, granted, now = (/* @__PURE__ */ new Date()).toISOString()) {
3113
+ if (!isPending(record))
3114
+ return { record, navigateTo: null };
3115
+ const url = record.request.url;
3116
+ return {
3117
+ record: {
3118
+ state: granted ? "granted" : "denied",
3119
+ request: null,
3120
+ history: [...record.history, { url, decision: granted ? "granted" : "denied", at: now }]
3121
+ },
3122
+ navigateTo: granted ? url : null
3123
+ };
3124
+ }
3125
+ function withdraw(record) {
3126
+ return isPending(record) ? { ...record, state: "idle", request: null } : record;
3127
+ }
3128
+
3129
+ // ../shared/dist/thread.js
3130
+ function firstMessageFromComment(comment) {
3131
+ return {
3132
+ id: `${comment.id}:0`,
3133
+ threadId: comment.id,
3134
+ author: { ...comment.author, type: "user" },
3135
+ body: comment.body,
3136
+ attachments: comment.attachments?.length ? comment.attachments : void 0,
3137
+ createdAt: comment.createdAt
3138
+ };
3139
+ }
3140
+ function threadConversation(comment, replies) {
3141
+ return [firstMessageFromComment(comment), ...replies].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3142
+ }
3143
+ function iterationLabel(link) {
3144
+ if (!link.parentThreadId || link.iterationType !== "revision")
3145
+ return null;
3146
+ return `Iteration ${link.iterationNumber ?? 2}`;
3147
+ }
3148
+
3149
+ // ../shared/dist/timeline.js
3150
+ function describeTarget(input) {
3151
+ const parts = [];
3152
+ if (input.anchor?.tag)
3153
+ parts.push(`a <${input.anchor.tag}>`);
3154
+ if (input.anchor?.selector)
3155
+ parts.push(`\`${input.anchor.selector}\``);
3156
+ return parts.length ? parts.join(" ") : input.kind === "free" ? "a page note" : "an element";
3157
+ }
3158
+ function threadTimeline(input, messages = []) {
3159
+ const out = [];
3160
+ const latest = messages.length ? messages[messages.length - 1].at : input.createdAt;
3161
+ out.push({
3162
+ at: input.createdAt,
3163
+ kind: "captured",
3164
+ label: "Feedback captured",
3165
+ detail: [input.title, describeTarget(input)].filter(Boolean).join(" \xB7 ")
3166
+ });
3167
+ for (const m of messages) {
3168
+ out.push({
3169
+ at: m.at,
3170
+ kind: "message",
3171
+ label: `${m.authorName} replied`,
3172
+ actor: m.fromAgent ? m.authorName : void 0
3173
+ });
2968
3174
  }
2969
- /**
2970
- * Offline mode: keep the file inline. localStorage is only a few MB, so refuse
2971
- * anything that would blow the quota rather than silently dropping it.
2972
- */
2973
- async upload(_projectKey, file) {
2974
- if (file.size > 3e6) throw new Error("attachment too large for offline mode");
2975
- return {
2976
- url: await fileToDataUrl(file),
2977
- name: file.name,
2978
- mime: file.type || void 0,
2979
- kind: attachmentKind(file.type),
2980
- size: file.size
2981
- };
2982
- }
2983
- /**
2984
- * Keys that hold a comment list. `loupe:dock` (panel state) and `loupe:msgs:*`
2985
- * (replies) share the prefix but are not comment lists — treating them as one made
2986
- * update()/remove() throw, and would have it hunt a comment id among messages.
2987
- */
2988
- commentKeys() {
2989
- const keys = [];
2990
- for (let i = 0; i < localStorage.length; i++) {
2991
- const k = localStorage.key(i);
2992
- if (k && k.startsWith("loupe:") && k !== "loupe:dock" && !k.startsWith("loupe:msgs:")) keys.push(k);
2993
- }
2994
- return keys;
3175
+ const stage = normalizeStage(input.status);
3176
+ const reached = (s) => STAGE_ORDER.indexOf(stage) >= STAGE_ORDER.indexOf(s);
3177
+ if (input.proposal || reached("in_review")) {
3178
+ const agent = input.proposal?.author ?? "Claude Code";
3179
+ out.push({
3180
+ at: input.proposal?.createdAt ?? latest,
3181
+ kind: "sent",
3182
+ label: `Change ready from ${agent}`,
3183
+ detail: "The rewritten markup is on the thread.",
3184
+ actor: agent
3185
+ });
2995
3186
  }
2996
- parseList(key) {
2997
- try {
2998
- const value = JSON.parse(localStorage.getItem(key) || "[]");
2999
- return Array.isArray(value) ? value : [];
3000
- } catch {
3001
- return [];
3187
+ if (input.pr) {
3188
+ out.push({
3189
+ at: latest,
3190
+ kind: "pr",
3191
+ label: input.pr.number ? `Pull request #${input.pr.number} opened` : "Pull request opened",
3192
+ detail: input.pr.state === "merged" ? "Merged." : input.pr.state === "closed" ? "Closed without merging." : "Waiting on review."
3193
+ });
3194
+ if (input.pr.previewUrl) {
3195
+ out.push({ at: latest, kind: "preview", label: "Preview live", detail: input.pr.previewUrl });
3002
3196
  }
3003
3197
  }
3004
- /** Replies, kept under their own key so they survive a comment being re-saved. */
3005
- msgKey(threadId) {
3006
- return `loupe:msgs:${threadId}`;
3198
+ if (reached("in_review")) {
3199
+ out.push({ at: latest, kind: "review", label: "Waiting on a human review", detail: "Only a person can close this." });
3007
3200
  }
3008
- async listMessages(threadId) {
3009
- try {
3010
- const raw = localStorage.getItem(this.msgKey(threadId));
3011
- const parsed = raw ? JSON.parse(raw) : [];
3012
- return Array.isArray(parsed) ? parsed : [];
3013
- } catch {
3014
- return [];
3015
- }
3201
+ if (stage === "resolved") {
3202
+ out.push({ at: latest, kind: "resolved", label: "Resolved", detail: "A person closed this." });
3016
3203
  }
3017
- async addMessage(threadId, message) {
3018
- const stored = {
3019
- id: `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
3020
- threadId,
3021
- author: message.author,
3022
- body: message.body,
3023
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
3024
- };
3025
- const all = await this.listMessages(threadId);
3026
- all.push(stored);
3027
- localStorage.setItem(this.msgKey(threadId), JSON.stringify(all));
3028
- return stored;
3204
+ return out;
3205
+ }
3206
+ var STAGE_ORDER = ["queue", "todo", "in_progress", "in_review", "resolved"];
3207
+ function normalizeStage(status) {
3208
+ if (status === "open")
3209
+ return "queue";
3210
+ if (status === "done")
3211
+ return "resolved";
3212
+ return status;
3213
+ }
3214
+ function threadAsText(input, messages = []) {
3215
+ const lines = [
3216
+ `# ${input.title ?? "Feedback"} (#${input.id})`,
3217
+ "",
3218
+ `- Stage: ${normalizeStage(input.status)}`,
3219
+ `- Page: ${input.url ?? "\u2014"}`
3220
+ ];
3221
+ if (input.anchor?.selector)
3222
+ lines.push(`- Target: \`${input.anchor.selector}\``);
3223
+ if (input.pr?.number)
3224
+ lines.push(`- PR: #${input.pr.number}${input.pr.previewUrl ? ` \xB7 preview ${input.pr.previewUrl}` : ""}`);
3225
+ lines.push("", "## Request", "", input.body);
3226
+ if (messages.length) {
3227
+ lines.push("", "## Conversation", "");
3228
+ for (const m of messages)
3229
+ lines.push(`**${m.authorName}**${m.fromAgent ? " (agent)" : ""} \u2014 ${m.at}`, "", m.body, "");
3029
3230
  }
3030
- /** Offline: the people are whoever has already commented locally. */
3031
- async listPeople(projectKey) {
3032
- const prefix = `loupe:${projectKey}:`;
3033
- const seen = /* @__PURE__ */ new Map();
3034
- for (let i = 0; i < localStorage.length; i++) {
3035
- const k = localStorage.key(i);
3036
- if (!k || !k.startsWith(prefix)) continue;
3037
- for (const c of this.parseList(k)) {
3038
- if (c.author?.id && !seen.has(c.author.id)) seen.set(c.author.id, c.author);
3039
- }
3040
- }
3041
- return [...seen.values()];
3231
+ return lines.join("\n");
3232
+ }
3233
+
3234
+ // ../shared/dist/mentions.js
3235
+ var HANDLE = /^[A-Za-z0-9][A-Za-z0-9._-]*/;
3236
+ function excludedRanges(body) {
3237
+ const ranges = [];
3238
+ for (const m of body.matchAll(/```[\s\S]*?```/g))
3239
+ ranges.push([m.index, m.index + m[0].length]);
3240
+ const fenced = (i) => ranges.some(([a, b]) => i >= a && i < b);
3241
+ for (const m of body.matchAll(/`[^`\n]*`/g)) {
3242
+ if (!fenced(m.index))
3243
+ ranges.push([m.index, m.index + m[0].length]);
3042
3244
  }
3043
- /** Offline mode has no server to notify anyone from. */
3044
- async listNotifications() {
3245
+ return ranges;
3246
+ }
3247
+ function parseMentions(body) {
3248
+ if (!body)
3045
3249
  return [];
3250
+ const excluded = excludedRanges(body);
3251
+ const inExcluded = (i) => excluded.some(([a, b]) => i >= a && i < b);
3252
+ const out = [];
3253
+ const seen = /* @__PURE__ */ new Set();
3254
+ for (let i = 0; i < body.length; i++) {
3255
+ if (body[i] !== "@")
3256
+ continue;
3257
+ if (inExcluded(i))
3258
+ continue;
3259
+ const prev = i > 0 ? body[i - 1] : "";
3260
+ if (prev && !/[\s([{<"'*_~]/.test(prev))
3261
+ continue;
3262
+ const match = HANDLE.exec(body.slice(i + 1));
3263
+ if (!match)
3264
+ continue;
3265
+ const raw = match[0];
3266
+ const handle = raw.replace(/[._-]+$/, "");
3267
+ if (!handle)
3268
+ continue;
3269
+ const key = handle.toLowerCase();
3270
+ if (seen.has(key))
3271
+ continue;
3272
+ seen.add(key);
3273
+ out.push({ handle, start: i, length: handle.length + 1 });
3046
3274
  }
3047
- async markNotificationsRead() {
3048
- }
3049
- async update(id, patch) {
3050
- for (const k of this.commentKeys()) {
3051
- const list = this.parseList(k);
3052
- const idx = list.findIndex((c) => c.id === id);
3053
- if (idx >= 0) {
3054
- list[idx] = { ...list[idx], ...patch };
3055
- localStorage.setItem(k, JSON.stringify(list));
3056
- return;
3057
- }
3058
- }
3059
- }
3060
- async remove(id) {
3061
- for (const k of this.commentKeys()) {
3062
- const list = this.parseList(k);
3063
- const next = list.filter((c) => c.id !== id);
3064
- if (next.length !== list.length) {
3065
- localStorage.setItem(k, JSON.stringify(next));
3066
- return;
3067
- }
3068
- }
3275
+ return out;
3276
+ }
3277
+ function mentionSegments(body, mentions) {
3278
+ if (!mentions.length)
3279
+ return [{ text: body, mention: false }];
3280
+ const sorted = [...mentions].sort((a, b) => a.start - b.start);
3281
+ const out = [];
3282
+ let at = 0;
3283
+ for (const m of sorted) {
3284
+ if (m.start > at)
3285
+ out.push({ text: body.slice(at, m.start), mention: false });
3286
+ out.push({ text: body.slice(m.start, m.start + m.length), mention: true });
3287
+ at = m.start + m.length;
3069
3288
  }
3070
- };
3289
+ if (at < body.length)
3290
+ out.push({ text: body.slice(at), mention: false });
3291
+ return out;
3292
+ }
3293
+ function mentionSuggestions(body, caret, candidates) {
3294
+ const before = body.slice(0, caret);
3295
+ const at = before.lastIndexOf("@");
3296
+ if (at < 0)
3297
+ return [];
3298
+ const typed = before.slice(at + 1);
3299
+ if (/\s/.test(typed))
3300
+ return [];
3301
+ const q = typed.toLowerCase();
3302
+ return candidates.filter((c) => !q || c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q)).slice(0, 6);
3303
+ }
3071
3304
 
3072
- // src/http-adapter.ts
3073
- var HttpAdapter = class {
3074
- constructor(base, user, userHmac, extraHeaders, credentials) {
3075
- this.base = base;
3076
- this.user = user;
3077
- this.userHmac = userHmac;
3078
- this.extraHeaders = extraHeaders;
3079
- this.credentials = credentials;
3080
- this.base = base.replace(/\/$/, "");
3081
- }
3082
- headers() {
3083
- const h = { "Content-Type": "application/json", "X-Loupe-User": this.user.id };
3084
- if (this.userHmac) h["X-Loupe-Hmac"] = this.userHmac;
3085
- if (this.extraHeaders) Object.assign(h, this.extraHeaders);
3086
- return h;
3305
+ // ../shared/dist/needs-you.js
3306
+ var NEEDS_YOU_LABELS = {
3307
+ review: "Waiting on your review",
3308
+ question: "The agent asked you something",
3309
+ failed: "The agent's run failed"
3310
+ };
3311
+ function looksLikeQuestion(body) {
3312
+ const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
3313
+ const last = lines[lines.length - 1];
3314
+ if (!last)
3315
+ return false;
3316
+ return /\?["')\]]*$/.test(last);
3317
+ }
3318
+ function needsYou(input) {
3319
+ const stage = normalizeStage2(input.status);
3320
+ if (stage === "resolved")
3321
+ return { needs: false };
3322
+ if (input.agentFailed)
3323
+ return { needs: true, reason: "failed", label: NEEDS_YOU_LABELS.failed };
3324
+ if (input.last?.fromAgent && looksLikeQuestion(input.last.body)) {
3325
+ return { needs: true, reason: "question", label: NEEDS_YOU_LABELS.question };
3087
3326
  }
3088
- /** Base fetch options shared by every request (credentials mode, if set). */
3089
- opts(init2) {
3090
- return this.credentials ? { credentials: this.credentials, ...init2 } : { ...init2 };
3327
+ if (stage === "in_review")
3328
+ return { needs: true, reason: "review", label: NEEDS_YOU_LABELS.review };
3329
+ return { needs: false };
3330
+ }
3331
+ function normalizeStage2(status) {
3332
+ if (status === "open")
3333
+ return "queue";
3334
+ if (status === "done")
3335
+ return "resolved";
3336
+ return status;
3337
+ }
3338
+
3339
+ // ../shared/dist/reactions.js
3340
+ var REACTION_CHOICES = ["\u{1F44D}", "\u{1F389}", "\u{1F440}", "\u{1F64F}", "\u2764\uFE0F", "\u{1F680}"];
3341
+ function summarizeReactions(reactions, viewerId) {
3342
+ const byEmoji = /* @__PURE__ */ new Map();
3343
+ for (const r of reactions) {
3344
+ const list = byEmoji.get(r.emoji) ?? [];
3345
+ list.push(r);
3346
+ byEmoji.set(r.emoji, list);
3347
+ }
3348
+ const rank = (emoji) => {
3349
+ const at = REACTION_CHOICES.indexOf(emoji);
3350
+ return at < 0 ? REACTION_CHOICES.length : at;
3351
+ };
3352
+ return [...byEmoji.entries()].map(([emoji, list]) => ({
3353
+ emoji,
3354
+ count: list.length,
3355
+ mine: viewerId ? list.some((r) => r.userId === viewerId) : false,
3356
+ users: list.map((r) => r.userName ?? r.userId)
3357
+ })).sort((a, b) => b.count - a.count || rank(a.emoji) - rank(b.emoji) || a.emoji.localeCompare(b.emoji));
3358
+ }
3359
+ function toggleReaction(reactions, next) {
3360
+ const has = reactions.some((r) => r.messageId === next.messageId && r.emoji === next.emoji && r.userId === next.userId);
3361
+ if (has) {
3362
+ return reactions.filter((r) => !(r.messageId === next.messageId && r.emoji === next.emoji && r.userId === next.userId));
3091
3363
  }
3092
- async list(projectKey, url) {
3093
- const q = new URLSearchParams({ projectKey, url });
3094
- const res = await fetch(`${this.base}/v1/comments?${q}`, this.opts({ headers: this.headers() }));
3364
+ return [...reactions, next];
3365
+ }
3366
+
3367
+ // ../shared/dist/presence.js
3368
+ var PEER_HEARTBEAT_MS = 6e3;
3369
+ function initialsOf(name) {
3370
+ const words = name.trim().split(/\s+/).filter(Boolean);
3371
+ if (!words.length)
3372
+ return "?";
3373
+ if (words.length === 1)
3374
+ return words[0].slice(0, 1).toUpperCase();
3375
+ return (words[0].slice(0, 1) + words[words.length - 1].slice(0, 1)).toUpperCase();
3376
+ }
3377
+
3378
+ // ../shared/dist/index.js
3379
+ var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
3380
+ var STAGE_LABELS = {
3381
+ queue: "Queue",
3382
+ todo: "To Do",
3383
+ in_progress: "In Progress",
3384
+ in_review: "In Review",
3385
+ resolved: "Resolved"
3386
+ };
3387
+ var LEGACY_STATUS = {
3388
+ open: "queue",
3389
+ in_progress: "in_progress",
3390
+ done: "resolved"
3391
+ };
3392
+ function normalizeStatus(value) {
3393
+ if (typeof value === "string") {
3394
+ if (COMMENT_STAGES.includes(value))
3395
+ return value;
3396
+ if (value in LEGACY_STATUS)
3397
+ return LEGACY_STATUS[value];
3398
+ }
3399
+ return "queue";
3400
+ }
3401
+ var COMMENT_PRIORITIES = ["critical", "high", "medium", "low"];
3402
+ var PRIORITY_LABELS = {
3403
+ critical: "Critical",
3404
+ high: "High",
3405
+ medium: "Medium",
3406
+ low: "Low"
3407
+ };
3408
+ var CHANGE_TYPES = ["frontend", "backend", "api", "other"];
3409
+ var CHANGE_TYPE_LABELS = {
3410
+ frontend: "Frontend",
3411
+ backend: "Backend",
3412
+ api: "API",
3413
+ other: "Other"
3414
+ };
3415
+ var DEFAULT_PRIORITY = "medium";
3416
+ var DEFAULT_CHANGE_TYPE = "other";
3417
+ function normalizePriority(value) {
3418
+ if (typeof value === "string" && COMMENT_PRIORITIES.includes(value)) {
3419
+ return value;
3420
+ }
3421
+ return DEFAULT_PRIORITY;
3422
+ }
3423
+
3424
+ // src/store.ts
3425
+ var LocalStorageAdapter = class {
3426
+ key(projectKey, url) {
3427
+ return `loupe:${projectKey}:${url}`;
3428
+ }
3429
+ readAll(projectKey, url) {
3430
+ try {
3431
+ const raw = localStorage.getItem(this.key(projectKey, url));
3432
+ return raw ? JSON.parse(raw) : [];
3433
+ } catch {
3434
+ return [];
3435
+ }
3436
+ }
3437
+ writeAll(projectKey, url, comments) {
3438
+ localStorage.setItem(this.key(projectKey, url), JSON.stringify(comments));
3439
+ }
3440
+ async list(projectKey, url) {
3441
+ return this.readAll(projectKey, url);
3442
+ }
3443
+ /** Every page's comments for this project, newest first — the "All" scope. */
3444
+ async listAll(projectKey) {
3445
+ const prefix = `loupe:${projectKey}:`;
3446
+ const out = [];
3447
+ for (let i = 0; i < localStorage.length; i++) {
3448
+ const k = localStorage.key(i);
3449
+ if (!k || !k.startsWith(prefix)) continue;
3450
+ try {
3451
+ out.push(...JSON.parse(localStorage.getItem(k) || "[]"));
3452
+ } catch {
3453
+ }
3454
+ }
3455
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
3456
+ }
3457
+ async save(comment) {
3458
+ const all = this.readAll(comment.projectKey, comment.url);
3459
+ all.push(comment);
3460
+ this.writeAll(comment.projectKey, comment.url, all);
3461
+ return comment;
3462
+ }
3463
+ /**
3464
+ * Offline mode: keep the file inline. localStorage is only a few MB, so refuse
3465
+ * anything that would blow the quota rather than silently dropping it.
3466
+ */
3467
+ async upload(_projectKey, file) {
3468
+ if (file.size > 3e6) throw new Error("attachment too large for offline mode");
3469
+ return {
3470
+ url: await fileToDataUrl(file),
3471
+ name: file.name,
3472
+ mime: file.type || void 0,
3473
+ kind: attachmentKind(file.type),
3474
+ size: file.size
3475
+ };
3476
+ }
3477
+ /**
3478
+ * Keys that hold a comment list. `loupe:dock` (panel state) and `loupe:msgs:*`
3479
+ * (replies) share the prefix but are not comment lists — treating them as one made
3480
+ * update()/remove() throw, and would have it hunt a comment id among messages.
3481
+ */
3482
+ commentKeys() {
3483
+ const keys = [];
3484
+ for (let i = 0; i < localStorage.length; i++) {
3485
+ const k = localStorage.key(i);
3486
+ if (k && k.startsWith("loupe:") && k !== "loupe:dock" && !k.startsWith("loupe:msgs:")) keys.push(k);
3487
+ }
3488
+ return keys;
3489
+ }
3490
+ parseList(key) {
3491
+ try {
3492
+ const value = JSON.parse(localStorage.getItem(key) || "[]");
3493
+ return Array.isArray(value) ? value : [];
3494
+ } catch {
3495
+ return [];
3496
+ }
3497
+ }
3498
+ /** Replies, kept under their own key so they survive a comment being re-saved. */
3499
+ msgKey(threadId) {
3500
+ return `loupe:msgs:${threadId}`;
3501
+ }
3502
+ async listMessages(threadId) {
3503
+ try {
3504
+ const raw = localStorage.getItem(this.msgKey(threadId));
3505
+ const parsed = raw ? JSON.parse(raw) : [];
3506
+ return Array.isArray(parsed) ? parsed : [];
3507
+ } catch {
3508
+ return [];
3509
+ }
3510
+ }
3511
+ async addMessage(threadId, message) {
3512
+ const stored = {
3513
+ id: `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
3514
+ threadId,
3515
+ author: message.author,
3516
+ body: message.body,
3517
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3518
+ };
3519
+ const all = await this.listMessages(threadId);
3520
+ all.push(stored);
3521
+ localStorage.setItem(this.msgKey(threadId), JSON.stringify(all));
3522
+ return stored;
3523
+ }
3524
+ /** Offline reactions, keyed per thread. */
3525
+ async listReactions(threadId) {
3526
+ try {
3527
+ const raw = localStorage.getItem(`loupe:rxn:${threadId}`);
3528
+ const parsed = raw ? JSON.parse(raw) : [];
3529
+ return Array.isArray(parsed) ? parsed : [];
3530
+ } catch {
3531
+ return [];
3532
+ }
3533
+ }
3534
+ async toggleReaction(input) {
3535
+ const current2 = await this.listReactions(input.threadId);
3536
+ const next = toggleReaction(current2, {
3537
+ messageId: input.messageId,
3538
+ emoji: input.emoji,
3539
+ userId: input.userId,
3540
+ userName: input.userName
3541
+ });
3542
+ localStorage.setItem(`loupe:rxn:${input.threadId}`, JSON.stringify(next));
3543
+ return next;
3544
+ }
3545
+ /** Offline: the people are whoever has already commented locally. */
3546
+ async listPeople(projectKey) {
3547
+ const prefix = `loupe:${projectKey}:`;
3548
+ const seen = /* @__PURE__ */ new Map();
3549
+ for (let i = 0; i < localStorage.length; i++) {
3550
+ const k = localStorage.key(i);
3551
+ if (!k || !k.startsWith(prefix)) continue;
3552
+ for (const c of this.parseList(k)) {
3553
+ if (c.author?.id && !seen.has(c.author.id)) seen.set(c.author.id, c.author);
3554
+ }
3555
+ }
3556
+ return [...seen.values()];
3557
+ }
3558
+ /** Offline mode has no server to notify anyone from. */
3559
+ async listNotifications() {
3560
+ return [];
3561
+ }
3562
+ async markNotificationsRead() {
3563
+ }
3564
+ async update(id, patch) {
3565
+ for (const k of this.commentKeys()) {
3566
+ const list = this.parseList(k);
3567
+ const idx = list.findIndex((c) => c.id === id);
3568
+ if (idx >= 0) {
3569
+ list[idx] = { ...list[idx], ...patch };
3570
+ localStorage.setItem(k, JSON.stringify(list));
3571
+ return;
3572
+ }
3573
+ }
3574
+ }
3575
+ async remove(id) {
3576
+ for (const k of this.commentKeys()) {
3577
+ const list = this.parseList(k);
3578
+ const next = list.filter((c) => c.id !== id);
3579
+ if (next.length !== list.length) {
3580
+ localStorage.setItem(k, JSON.stringify(next));
3581
+ return;
3582
+ }
3583
+ }
3584
+ }
3585
+ };
3586
+
3587
+ // src/http-adapter.ts
3588
+ var HttpAdapter = class {
3589
+ constructor(base, user, userHmac, extraHeaders, credentials) {
3590
+ this.base = base;
3591
+ this.user = user;
3592
+ this.userHmac = userHmac;
3593
+ this.extraHeaders = extraHeaders;
3594
+ this.credentials = credentials;
3595
+ this.base = base.replace(/\/$/, "");
3596
+ }
3597
+ headers() {
3598
+ const h = { "Content-Type": "application/json", "X-Loupe-User": this.user.id };
3599
+ if (this.userHmac) h["X-Loupe-Hmac"] = this.userHmac;
3600
+ if (this.extraHeaders) Object.assign(h, this.extraHeaders);
3601
+ return h;
3602
+ }
3603
+ /** Base fetch options shared by every request (credentials mode, if set). */
3604
+ opts(init2) {
3605
+ return this.credentials ? { credentials: this.credentials, ...init2 } : { ...init2 };
3606
+ }
3607
+ async list(projectKey, url) {
3608
+ const q = new URLSearchParams({ projectKey, url });
3609
+ const res = await fetch(`${this.base}/v1/comments?${q}`, this.opts({ headers: this.headers() }));
3095
3610
  if (!res.ok) throw new Error(`list failed: ${res.status}`);
3096
3611
  return await res.json();
3097
3612
  }
@@ -3148,471 +3663,71 @@ var Loupe = (() => {
3148
3663
  return await res.json();
3149
3664
  }
3150
3665
  async addMessage(threadId, message) {
3151
- const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(threadId)}/messages`, this.opts({
3152
- method: "POST",
3153
- headers: this.headers(),
3154
- body: JSON.stringify(message)
3155
- }));
3156
- if (!res.ok) throw new Error(`addMessage failed: ${res.status}`);
3157
- return await res.json();
3158
- }
3159
- /** Everyone who has taken part in this project, for mention resolution. */
3160
- async listPeople(projectKey) {
3161
- const q = new URLSearchParams({ projectKey });
3162
- const res = await fetch(`${this.base}/v1/people?${q}`, this.opts({ headers: this.headers() }));
3163
- if (!res.ok) throw new Error(`listPeople failed: ${res.status}`);
3164
- return await res.json();
3165
- }
3166
- async listNotifications(projectKey, recipient) {
3167
- const q = new URLSearchParams({ projectKey, recipient });
3168
- const res = await fetch(`${this.base}/v1/notifications?${q}`, this.opts({ headers: this.headers() }));
3169
- if (!res.ok) throw new Error(`listNotifications failed: ${res.status}`);
3170
- return (await res.json()).notifications;
3171
- }
3172
- async markNotificationsRead(projectKey, recipient, id) {
3173
- await fetch(`${this.base}/v1/notifications/read`, this.opts({
3174
- method: "POST",
3175
- headers: this.headers(),
3176
- body: JSON.stringify({ projectKey, recipient, id })
3177
- }));
3178
- }
3179
- async update(id, patch) {
3180
- const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3181
- method: "PATCH",
3182
- headers: this.headers(),
3183
- body: JSON.stringify(patch)
3184
- }));
3185
- if (!res.ok) throw new Error(`update failed: ${res.status}`);
3186
- }
3187
- async remove(id) {
3188
- const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3189
- method: "DELETE",
3190
- headers: this.headers()
3191
- }));
3192
- if (!res.ok && res.status !== 404) throw new Error(`remove failed: ${res.status}`);
3193
- }
3194
- };
3195
-
3196
- // ../shared/dist/activity.js
3197
- var ACTIVITY_STATUS_LABELS = {
3198
- idle: "Idle",
3199
- working: "Working",
3200
- error: "Error"
3201
- };
3202
- function formatDuration(ms) {
3203
- if (!ms || ms < 0)
3204
- return "\u2014";
3205
- const s = Math.round(ms / 1e3);
3206
- if (s < 60)
3207
- return `${s}s`;
3208
- const m = Math.floor(s / 60);
3209
- const rest = s % 60;
3210
- if (m < 60)
3211
- return rest ? `${m}m ${rest}s` : `${m}m`;
3212
- const h = Math.floor(m / 60);
3213
- return `${h}h ${m % 60}m`;
3214
- }
3215
- function summarizeActivity(events, status = "idle", now = Date.now()) {
3216
- const files = /* @__PURE__ */ new Set();
3217
- const kinds = /* @__PURE__ */ new Map();
3218
- let errors = 0;
3219
- let first = Infinity;
3220
- for (const e of events) {
3221
- for (const f of e.files ?? [])
3222
- files.add(f);
3223
- kinds.set(e.kind, (kinds.get(e.kind) ?? 0) + 1);
3224
- if (e.level === "error")
3225
- errors++;
3226
- const t = Date.parse(e.at);
3227
- if (!Number.isNaN(t))
3228
- first = Math.min(first, t);
3229
- }
3230
- return {
3231
- status,
3232
- events: events.length,
3233
- errors,
3234
- files: files.size,
3235
- durationMs: events.length && first !== Infinity ? Math.max(0, now - first) : 0,
3236
- byKind: [...kinds.entries()].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind))
3237
- };
3238
- }
3239
-
3240
- // ../shared/dist/lifecycle.js
3241
- var LIFECYCLE_LABELS = {
3242
- sent: "Sent to agent",
3243
- in_pr: "In PR",
3244
- preview: "Review preview",
3245
- reviewed: "Reviewed"
3246
- };
3247
- function lifecycle(c) {
3248
- const hasProposal = !!c.proposal;
3249
- const pr = c.pr ?? void 0;
3250
- const withPr = (stage) => {
3251
- const out = { stage, label: LIFECYCLE_LABELS[stage] };
3252
- if (pr) {
3253
- out.pr = pr;
3254
- if (typeof pr.checksTotal === "number" && pr.checksTotal > 0) {
3255
- const passed = Math.max(0, Math.min(pr.checksTotal, pr.checksPassed ?? 0));
3256
- out.checks = {
3257
- text: `${passed}/${pr.checksTotal}`,
3258
- ratio: passed / pr.checksTotal
3259
- };
3260
- }
3261
- }
3262
- return out;
3263
- };
3264
- if (c.status === "resolved")
3265
- return hasProposal || pr ? withPr("reviewed") : null;
3266
- if (pr)
3267
- return withPr("in_pr");
3268
- if (c.status === "in_review")
3269
- return { stage: "preview", label: LIFECYCLE_LABELS.preview };
3270
- if (hasProposal)
3271
- return { stage: "sent", label: LIFECYCLE_LABELS.sent };
3272
- return null;
3273
- }
3274
- function awaitingReview(comments) {
3275
- return comments.filter((c) => c.status === "in_review");
3276
- }
3277
-
3278
- // ../shared/dist/iteration.js
3279
- function emptyIterations() {
3280
- return { items: [], index: -1 };
3281
- }
3282
- function current(state) {
3283
- return state.index >= 0 ? state.items[state.index] ?? null : null;
3284
- }
3285
- function addIteration(state, iteration) {
3286
- const head = state.items.slice(0, state.index + 1);
3287
- const items = [...head, iteration].slice(-MAX_ITERATIONS);
3288
- return { items, index: items.length - 1 };
3289
- }
3290
- var MAX_ITERATIONS = 20;
3291
- function canUndo(state) {
3292
- return state.items.length > 0;
3293
- }
3294
- function undo(state) {
3295
- if (!state.items.length)
3296
- return state;
3297
- const items = state.items.slice(0, -1);
3298
- return { items, index: items.length - 1 };
3299
- }
3300
- function canMove(state, delta) {
3301
- const next = state.index + delta;
3302
- return next >= 0 && next < state.items.length;
3303
- }
3304
- function move(state, delta) {
3305
- return canMove(state, delta) ? { ...state, index: state.index + delta } : state;
3306
- }
3307
- function stackLabel(state) {
3308
- return state.items.length ? `${state.index + 1} / ${state.items.length}` : "";
3309
- }
3310
-
3311
- // ../shared/dist/consent.js
3312
- function emptyConsent() {
3313
- return { state: "idle", request: null, history: [] };
3314
- }
3315
- function isNavigableUrl(raw) {
3316
- try {
3317
- const u = new URL(raw);
3318
- return u.protocol === "http:" || u.protocol === "https:";
3319
- } catch {
3320
- return false;
3321
- }
3322
- }
3323
- function requestNavigation(record, request) {
3324
- if (!isNavigableUrl(request.url))
3325
- return record;
3326
- return {
3327
- state: "requested",
3328
- request: {
3329
- id: request.id ?? `nav${Date.now().toString(36)}`,
3330
- at: request.at ?? (/* @__PURE__ */ new Date()).toISOString(),
3331
- url: request.url,
3332
- reason: request.reason,
3333
- requester: request.requester
3334
- },
3335
- history: record.history
3336
- };
3337
- }
3338
- function isPending(record) {
3339
- return record.state === "requested" && record.request !== null;
3340
- }
3341
- function decide(record, granted, now = (/* @__PURE__ */ new Date()).toISOString()) {
3342
- if (!isPending(record))
3343
- return { record, navigateTo: null };
3344
- const url = record.request.url;
3345
- return {
3346
- record: {
3347
- state: granted ? "granted" : "denied",
3348
- request: null,
3349
- history: [...record.history, { url, decision: granted ? "granted" : "denied", at: now }]
3350
- },
3351
- navigateTo: granted ? url : null
3352
- };
3353
- }
3354
- function withdraw(record) {
3355
- return isPending(record) ? { ...record, state: "idle", request: null } : record;
3356
- }
3357
-
3358
- // ../shared/dist/thread.js
3359
- function firstMessageFromComment(comment) {
3360
- return {
3361
- id: `${comment.id}:0`,
3362
- threadId: comment.id,
3363
- author: { ...comment.author, type: "user" },
3364
- body: comment.body,
3365
- attachments: comment.attachments?.length ? comment.attachments : void 0,
3366
- createdAt: comment.createdAt
3367
- };
3368
- }
3369
- function threadConversation(comment, replies) {
3370
- return [firstMessageFromComment(comment), ...replies].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3371
- }
3372
- function iterationLabel(link) {
3373
- if (!link.parentThreadId || link.iterationType !== "revision")
3374
- return null;
3375
- return `Iteration ${link.iterationNumber ?? 2}`;
3376
- }
3377
-
3378
- // ../shared/dist/timeline.js
3379
- function describeTarget(input) {
3380
- const parts = [];
3381
- if (input.anchor?.tag)
3382
- parts.push(`a <${input.anchor.tag}>`);
3383
- if (input.anchor?.selector)
3384
- parts.push(`\`${input.anchor.selector}\``);
3385
- return parts.length ? parts.join(" ") : input.kind === "free" ? "a page note" : "an element";
3386
- }
3387
- function threadTimeline(input, messages = []) {
3388
- const out = [];
3389
- const latest = messages.length ? messages[messages.length - 1].at : input.createdAt;
3390
- out.push({
3391
- at: input.createdAt,
3392
- kind: "captured",
3393
- label: "Feedback captured",
3394
- detail: [input.title, describeTarget(input)].filter(Boolean).join(" \xB7 ")
3395
- });
3396
- for (const m of messages) {
3397
- out.push({
3398
- at: m.at,
3399
- kind: "message",
3400
- label: `${m.authorName} replied`,
3401
- actor: m.fromAgent ? m.authorName : void 0
3402
- });
3403
- }
3404
- const stage = normalizeStage(input.status);
3405
- const reached = (s) => STAGE_ORDER.indexOf(stage) >= STAGE_ORDER.indexOf(s);
3406
- if (input.proposal || reached("in_review")) {
3407
- const agent = input.proposal?.author ?? "Claude Code";
3408
- out.push({
3409
- at: input.proposal?.createdAt ?? latest,
3410
- kind: "sent",
3411
- label: `Change ready from ${agent}`,
3412
- detail: "The rewritten markup is on the thread.",
3413
- actor: agent
3414
- });
3415
- }
3416
- if (input.pr) {
3417
- out.push({
3418
- at: latest,
3419
- kind: "pr",
3420
- label: input.pr.number ? `Pull request #${input.pr.number} opened` : "Pull request opened",
3421
- detail: input.pr.state === "merged" ? "Merged." : input.pr.state === "closed" ? "Closed without merging." : "Waiting on review."
3422
- });
3423
- if (input.pr.previewUrl) {
3424
- out.push({ at: latest, kind: "preview", label: "Preview live", detail: input.pr.previewUrl });
3425
- }
3426
- }
3427
- if (reached("in_review")) {
3428
- out.push({ at: latest, kind: "review", label: "Waiting on a human review", detail: "Only a person can close this." });
3666
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(threadId)}/messages`, this.opts({
3667
+ method: "POST",
3668
+ headers: this.headers(),
3669
+ body: JSON.stringify(message)
3670
+ }));
3671
+ if (!res.ok) throw new Error(`addMessage failed: ${res.status}`);
3672
+ return await res.json();
3429
3673
  }
3430
- if (stage === "resolved") {
3431
- out.push({ at: latest, kind: "resolved", label: "Resolved", detail: "A person closed this." });
3674
+ async listReactions(threadId) {
3675
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(threadId)}/messages/all/reactions`, this.opts({ headers: this.headers() }));
3676
+ if (res.status === 404) return [];
3677
+ if (!res.ok) throw new Error(`listReactions failed: ${res.status}`);
3678
+ return (await res.json()).reactions;
3432
3679
  }
3433
- return out;
3434
- }
3435
- var STAGE_ORDER = ["queue", "todo", "in_progress", "in_review", "resolved"];
3436
- function normalizeStage(status) {
3437
- if (status === "open")
3438
- return "queue";
3439
- if (status === "done")
3440
- return "resolved";
3441
- return status;
3442
- }
3443
- function threadAsText(input, messages = []) {
3444
- const lines = [
3445
- `# ${input.title ?? "Feedback"} (#${input.id})`,
3446
- "",
3447
- `- Stage: ${normalizeStage(input.status)}`,
3448
- `- Page: ${input.url ?? "\u2014"}`
3449
- ];
3450
- if (input.anchor?.selector)
3451
- lines.push(`- Target: \`${input.anchor.selector}\``);
3452
- if (input.pr?.number)
3453
- lines.push(`- PR: #${input.pr.number}${input.pr.previewUrl ? ` \xB7 preview ${input.pr.previewUrl}` : ""}`);
3454
- lines.push("", "## Request", "", input.body);
3455
- if (messages.length) {
3456
- lines.push("", "## Conversation", "");
3457
- for (const m of messages)
3458
- lines.push(`**${m.authorName}**${m.fromAgent ? " (agent)" : ""} \u2014 ${m.at}`, "", m.body, "");
3680
+ async toggleReaction(input) {
3681
+ const res = await fetch(
3682
+ `${this.base}/v1/comments/${encodeURIComponent(input.threadId)}/messages/${encodeURIComponent(input.messageId)}/reactions`,
3683
+ this.opts({
3684
+ method: "POST",
3685
+ headers: this.headers(),
3686
+ body: JSON.stringify({ emoji: input.emoji, userId: input.userId, userName: input.userName })
3687
+ })
3688
+ );
3689
+ if (!res.ok) throw new Error(`toggleReaction failed: ${res.status}`);
3690
+ return (await res.json()).reactions;
3459
3691
  }
3460
- return lines.join("\n");
3461
- }
3462
-
3463
- // ../shared/dist/mentions.js
3464
- var HANDLE = /^[A-Za-z0-9][A-Za-z0-9._-]*/;
3465
- function excludedRanges(body) {
3466
- const ranges = [];
3467
- for (const m of body.matchAll(/```[\s\S]*?```/g))
3468
- ranges.push([m.index, m.index + m[0].length]);
3469
- const fenced = (i) => ranges.some(([a, b]) => i >= a && i < b);
3470
- for (const m of body.matchAll(/`[^`\n]*`/g)) {
3471
- if (!fenced(m.index))
3472
- ranges.push([m.index, m.index + m[0].length]);
3692
+ /** Everyone who has taken part in this project, for mention resolution. */
3693
+ async listPeople(projectKey) {
3694
+ const q = new URLSearchParams({ projectKey });
3695
+ const res = await fetch(`${this.base}/v1/people?${q}`, this.opts({ headers: this.headers() }));
3696
+ if (!res.ok) throw new Error(`listPeople failed: ${res.status}`);
3697
+ return await res.json();
3473
3698
  }
3474
- return ranges;
3475
- }
3476
- function parseMentions(body) {
3477
- if (!body)
3478
- return [];
3479
- const excluded = excludedRanges(body);
3480
- const inExcluded = (i) => excluded.some(([a, b]) => i >= a && i < b);
3481
- const out = [];
3482
- const seen = /* @__PURE__ */ new Set();
3483
- for (let i = 0; i < body.length; i++) {
3484
- if (body[i] !== "@")
3485
- continue;
3486
- if (inExcluded(i))
3487
- continue;
3488
- const prev = i > 0 ? body[i - 1] : "";
3489
- if (prev && !/[\s([{<"'*_~]/.test(prev))
3490
- continue;
3491
- const match = HANDLE.exec(body.slice(i + 1));
3492
- if (!match)
3493
- continue;
3494
- const raw = match[0];
3495
- const handle = raw.replace(/[._-]+$/, "");
3496
- if (!handle)
3497
- continue;
3498
- const key = handle.toLowerCase();
3499
- if (seen.has(key))
3500
- continue;
3501
- seen.add(key);
3502
- out.push({ handle, start: i, length: handle.length + 1 });
3699
+ async listNotifications(projectKey, recipient) {
3700
+ const q = new URLSearchParams({ projectKey, recipient });
3701
+ const res = await fetch(`${this.base}/v1/notifications?${q}`, this.opts({ headers: this.headers() }));
3702
+ if (!res.ok) throw new Error(`listNotifications failed: ${res.status}`);
3703
+ return (await res.json()).notifications;
3503
3704
  }
3504
- return out;
3505
- }
3506
- function mentionSegments(body, mentions) {
3507
- if (!mentions.length)
3508
- return [{ text: body, mention: false }];
3509
- const sorted = [...mentions].sort((a, b) => a.start - b.start);
3510
- const out = [];
3511
- let at = 0;
3512
- for (const m of sorted) {
3513
- if (m.start > at)
3514
- out.push({ text: body.slice(at, m.start), mention: false });
3515
- out.push({ text: body.slice(m.start, m.start + m.length), mention: true });
3516
- at = m.start + m.length;
3705
+ async markNotificationsRead(projectKey, recipient, id) {
3706
+ await fetch(`${this.base}/v1/notifications/read`, this.opts({
3707
+ method: "POST",
3708
+ headers: this.headers(),
3709
+ body: JSON.stringify({ projectKey, recipient, id })
3710
+ }));
3517
3711
  }
3518
- if (at < body.length)
3519
- out.push({ text: body.slice(at), mention: false });
3520
- return out;
3521
- }
3522
- function mentionSuggestions(body, caret, candidates) {
3523
- const before = body.slice(0, caret);
3524
- const at = before.lastIndexOf("@");
3525
- if (at < 0)
3526
- return [];
3527
- const typed = before.slice(at + 1);
3528
- if (/\s/.test(typed))
3529
- return [];
3530
- const q = typed.toLowerCase();
3531
- return candidates.filter((c) => !q || c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q)).slice(0, 6);
3532
- }
3533
-
3534
- // ../shared/dist/needs-you.js
3535
- var NEEDS_YOU_LABELS = {
3536
- review: "Waiting on your review",
3537
- question: "The agent asked you something",
3538
- failed: "The agent's run failed"
3539
- };
3540
- function looksLikeQuestion(body) {
3541
- const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
3542
- const last = lines[lines.length - 1];
3543
- if (!last)
3544
- return false;
3545
- return /\?["')\]]*$/.test(last);
3546
- }
3547
- function needsYou(input) {
3548
- const stage = normalizeStage2(input.status);
3549
- if (stage === "resolved")
3550
- return { needs: false };
3551
- if (input.agentFailed)
3552
- return { needs: true, reason: "failed", label: NEEDS_YOU_LABELS.failed };
3553
- if (input.last?.fromAgent && looksLikeQuestion(input.last.body)) {
3554
- return { needs: true, reason: "question", label: NEEDS_YOU_LABELS.question };
3712
+ async update(id, patch) {
3713
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3714
+ method: "PATCH",
3715
+ headers: this.headers(),
3716
+ body: JSON.stringify(patch)
3717
+ }));
3718
+ if (!res.ok) throw new Error(`update failed: ${res.status}`);
3555
3719
  }
3556
- if (stage === "in_review")
3557
- return { needs: true, reason: "review", label: NEEDS_YOU_LABELS.review };
3558
- return { needs: false };
3559
- }
3560
- function normalizeStage2(status) {
3561
- if (status === "open")
3562
- return "queue";
3563
- if (status === "done")
3564
- return "resolved";
3565
- return status;
3566
- }
3567
-
3568
- // ../shared/dist/index.js
3569
- var COMMENT_STAGES = ["queue", "todo", "in_progress", "in_review", "resolved"];
3570
- var STAGE_LABELS = {
3571
- queue: "Queue",
3572
- todo: "To Do",
3573
- in_progress: "In Progress",
3574
- in_review: "In Review",
3575
- resolved: "Resolved"
3576
- };
3577
- var LEGACY_STATUS = {
3578
- open: "queue",
3579
- in_progress: "in_progress",
3580
- done: "resolved"
3581
- };
3582
- function normalizeStatus(value) {
3583
- if (typeof value === "string") {
3584
- if (COMMENT_STAGES.includes(value))
3585
- return value;
3586
- if (value in LEGACY_STATUS)
3587
- return LEGACY_STATUS[value];
3720
+ async remove(id) {
3721
+ const res = await fetch(`${this.base}/v1/comments/${encodeURIComponent(id)}`, this.opts({
3722
+ method: "DELETE",
3723
+ headers: this.headers()
3724
+ }));
3725
+ if (!res.ok && res.status !== 404) throw new Error(`remove failed: ${res.status}`);
3588
3726
  }
3589
- return "queue";
3590
- }
3591
- var COMMENT_PRIORITIES = ["critical", "high", "medium", "low"];
3592
- var PRIORITY_LABELS = {
3593
- critical: "Critical",
3594
- high: "High",
3595
- medium: "Medium",
3596
- low: "Low"
3597
- };
3598
- var CHANGE_TYPES = ["frontend", "backend", "api", "other"];
3599
- var CHANGE_TYPE_LABELS = {
3600
- frontend: "Frontend",
3601
- backend: "Backend",
3602
- api: "API",
3603
- other: "Other"
3604
3727
  };
3605
- var DEFAULT_PRIORITY = "medium";
3606
- var DEFAULT_CHANGE_TYPE = "other";
3607
- function normalizePriority(value) {
3608
- if (typeof value === "string" && COMMENT_PRIORITIES.includes(value)) {
3609
- return value;
3610
- }
3611
- return DEFAULT_PRIORITY;
3612
- }
3613
3728
 
3614
3729
  // src/app.ts
3615
- var SDK_VERSION = true ? "0.10.23" : "dev";
3730
+ var SDK_VERSION = true ? "0.10.24" : "dev";
3616
3731
  var ACCENTS = [
3617
3732
  { id: "indigo", dark: "#6b73e6", light: "#4a55d6", soft: "rgba(107,115,230,0.12)" },
3618
3733
  { id: "violet", dark: "#a06be6", light: "#7c3fd4", soft: "rgba(160,107,230,0.14)" },
@@ -4030,6 +4145,10 @@ var Loupe = (() => {
4030
4145
  /** Optimistic replies the store rejected — offered for retry rather than lost. */
4031
4146
  this.msgFailed = /* @__PURE__ */ new Set();
4032
4147
  this.msgErr = /* @__PURE__ */ new Map();
4148
+ /** Other people on this page. Empty unless a bridge is configured. */
4149
+ this.peers = [];
4150
+ /** Reactions per thread, keyed `${threadId}:${messageId}`, so a re-render is free. */
4151
+ this.reactions = /* @__PURE__ */ new Map();
4033
4152
  /** Everyone who has taken part, for mention autocomplete. Fetched once. */
4034
4153
  this.people = /* @__PURE__ */ new Map();
4035
4154
  this.cfg = cfg;
@@ -4049,6 +4168,7 @@ var Loupe = (() => {
4049
4168
  this.renderPins();
4050
4169
  this.renderList();
4051
4170
  this.renderHome();
4171
+ this.startPresence();
4052
4172
  void this.loadNotifications();
4053
4173
  if (this.scope === "all") void this.loadAllComments();
4054
4174
  this.observe();
@@ -4198,7 +4318,9 @@ var Loupe = (() => {
4198
4318
  closeBtn.innerHTML = I_CLOSE;
4199
4319
  closeBtn.onclick = () => this.closeDock();
4200
4320
  ctl.append(posWrap, this.themeBtn, setWrap, minBtn, closeBtn);
4201
- head.append(brand, ctl);
4321
+ this.peerEl = el("div", "peers");
4322
+ if (!this.cfg.bridge) this.peerEl.style.display = "none";
4323
+ head.append(brand, this.peerEl, ctl);
4202
4324
  this.minBar = el("div", "minbar");
4203
4325
  this.minBar.onclick = () => this.setMinimized(false);
4204
4326
  this.tabList = [...BUILTIN_TABS, ...(this.cfg.tabs ?? []).map((t) => ({ id: t.id, label: t.label }))];
@@ -6358,6 +6480,37 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6358
6480
  else body.appendChild(document.createTextNode(seg.text));
6359
6481
  }
6360
6482
  row.append(head, body);
6483
+ const pills = el("div", "rxns");
6484
+ for (const r of summarizeReactions(this.reactionsOf(c.id, m.id), this.cfg.user.id)) {
6485
+ const pill = el("button", "rxn" + (r.mine ? " mine" : ""));
6486
+ pill.append(document.createTextNode(r.emoji), el("span", "rxn-n", String(r.count)));
6487
+ pill.title = r.users.join(", ");
6488
+ pill.onclick = (e) => {
6489
+ e.stopPropagation();
6490
+ void this.react(c, m.id, r.emoji);
6491
+ };
6492
+ pills.appendChild(pill);
6493
+ }
6494
+ const add = el("button", "rxn-add", "\uFF0B");
6495
+ add.title = "Add a reaction";
6496
+ add.onclick = (e) => {
6497
+ e.stopPropagation();
6498
+ const open = pills.querySelector(".rxn-pick");
6499
+ pills.querySelectorAll(".rxn-pick").forEach((n) => n.remove());
6500
+ if (open) return;
6501
+ const pick = el("div", "rxn-pick");
6502
+ for (const emoji of REACTION_CHOICES) {
6503
+ const b = el("button", "rxn-opt", emoji);
6504
+ b.onclick = (ev) => {
6505
+ ev.stopPropagation();
6506
+ void this.react(c, m.id, emoji);
6507
+ };
6508
+ pick.appendChild(b);
6509
+ }
6510
+ pills.appendChild(pick);
6511
+ };
6512
+ pills.appendChild(add);
6513
+ row.appendChild(pills);
6361
6514
  if (this.msgPending.has(m.id)) row.appendChild(el("div", "msg-state", "Sending\u2026"));
6362
6515
  if (this.msgFailed.has(m.id)) {
6363
6516
  const failed = el("div", "msg-state failed");
@@ -6469,6 +6622,108 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6469
6622
  wrap.appendChild(copyRow);
6470
6623
  return wrap;
6471
6624
  }
6625
+ /**
6626
+ * Join, then keep talking.
6627
+ *
6628
+ * Presence is a heartbeat, not a flag, because a browser that is closed or crashes
6629
+ * sends no goodbye. The bridge expires anything silent past the TTL, and the panel
6630
+ * re-joins on a 404 rather than assuming it is still known.
6631
+ */
6632
+ startPresence() {
6633
+ const bridge = this.cfg.bridge;
6634
+ if (!bridge) return;
6635
+ const base = bridge.replace(/\/$/, "");
6636
+ const pageUrl = location.pathname + location.search;
6637
+ const tick = async () => {
6638
+ try {
6639
+ if (!this.peerId) {
6640
+ const res = await fetch(`${base}/presence`, {
6641
+ method: "POST",
6642
+ headers: { "content-type": "application/json" },
6643
+ body: JSON.stringify({ url: pageUrl, userId: this.cfg.user.id, name: this.cfg.user.name })
6644
+ });
6645
+ if (!res.ok) return;
6646
+ const data = await res.json();
6647
+ this.peerId = data.peer?.id;
6648
+ } else {
6649
+ const res = await fetch(`${base}/presence/${encodeURIComponent(this.peerId)}/heartbeat`, { method: "POST" });
6650
+ if (res.status === 404) this.peerId = void 0;
6651
+ }
6652
+ const listed = await fetch(
6653
+ `${base}/presence?url=${encodeURIComponent(pageUrl)}&viewer=${encodeURIComponent(this.peerId ?? "")}`
6654
+ );
6655
+ if (listed.ok) {
6656
+ const data = await listed.json();
6657
+ this.peers = Array.isArray(data.peers) ? data.peers : [];
6658
+ this.renderPeers();
6659
+ }
6660
+ } catch {
6661
+ this.peers = [];
6662
+ this.renderPeers();
6663
+ }
6664
+ };
6665
+ void tick();
6666
+ this.peerTimer = setInterval(() => void tick(), PEER_HEARTBEAT_MS);
6667
+ }
6668
+ renderPeers() {
6669
+ if (!this.peerEl) return;
6670
+ this.peerEl.textContent = "";
6671
+ if (!this.peers.length) {
6672
+ this.peerEl.style.display = "none";
6673
+ return;
6674
+ }
6675
+ this.peerEl.style.display = "";
6676
+ for (const p of this.peers.slice(0, 4)) {
6677
+ const av = el("span", "peer-av", initialsOf(p.name));
6678
+ av.title = `${p.name} is on this page`;
6679
+ this.peerEl.appendChild(av);
6680
+ }
6681
+ if (this.peers.length > 4) this.peerEl.appendChild(el("span", "peer-more", `+${this.peers.length - 4}`));
6682
+ }
6683
+ stopPresence() {
6684
+ if (this.peerTimer) clearInterval(this.peerTimer);
6685
+ this.peerTimer = void 0;
6686
+ if (this.cfg.bridge && this.peerId) {
6687
+ const base = this.cfg.bridge.replace(/\/$/, "");
6688
+ void fetch(`${base}/presence/${encodeURIComponent(this.peerId)}`, { method: "DELETE", keepalive: true }).catch(() => {
6689
+ });
6690
+ }
6691
+ this.peerId = void 0;
6692
+ this.peers = [];
6693
+ }
6694
+ reactionsOf(threadId, messageId) {
6695
+ return this.reactions.get(`${threadId}:${messageId}`) ?? [];
6696
+ }
6697
+ /**
6698
+ * Toggle a reaction.
6699
+ *
6700
+ * Optimistic, then replaced by the server's own set: the server returns the whole
6701
+ * list precisely so the count can never drift from what was stored.
6702
+ */
6703
+ async react(c, messageId, emoji) {
6704
+ const key = `${c.id}:${messageId}`;
6705
+ const before = this.reactionsOf(c.id, messageId);
6706
+ this.reactions.set(key, toggleReaction(before, {
6707
+ messageId,
6708
+ emoji,
6709
+ userId: this.cfg.user.id,
6710
+ userName: this.cfg.user.name
6711
+ }));
6712
+ this.renderList();
6713
+ try {
6714
+ const next = await this.store.toggleReaction({
6715
+ threadId: c.id,
6716
+ messageId,
6717
+ emoji,
6718
+ userId: this.cfg.user.id,
6719
+ userName: this.cfg.user.name
6720
+ });
6721
+ this.reactions.set(key, Array.isArray(next) ? next : this.reactionsOf(c.id, messageId));
6722
+ } catch {
6723
+ this.reactions.set(key, before);
6724
+ }
6725
+ this.renderList();
6726
+ }
6472
6727
  async loadPeople() {
6473
6728
  if (this.people.has(this.cfg.projectKey)) return;
6474
6729
  try {
@@ -6481,6 +6736,17 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6481
6736
  async loadMessages(c) {
6482
6737
  if (this.messages.has(c.id)) return;
6483
6738
  void this.loadPeople();
6739
+ void this.store.listReactions(c.id).then((all) => {
6740
+ if (!Array.isArray(all)) return;
6741
+ for (const r of all) {
6742
+ const key = `${c.id}:${r.messageId}`;
6743
+ const list = this.reactions.get(key) ?? [];
6744
+ if (!list.some((x) => x.userId === r.userId && x.emoji === r.emoji)) list.push(r);
6745
+ this.reactions.set(key, list);
6746
+ }
6747
+ this.renderList();
6748
+ }).catch(() => {
6749
+ });
6484
6750
  try {
6485
6751
  this.messages.set(c.id, await this.store.listMessages(c.id));
6486
6752
  } catch {
@@ -6627,6 +6893,7 @@ ${c.body}` : c.body)}</div>` + (c.context?.html ? `<pre class="or-code">${escape
6627
6893
  if (elx) elx.scrollIntoView({ behavior: "smooth", block: "center" });
6628
6894
  }
6629
6895
  destroy() {
6896
+ this.stopPresence();
6630
6897
  this.stopRecording?.();
6631
6898
  this.setMode("off");
6632
6899
  this.mo?.disconnect();