hale-commenting-system 2.0.3 → 2.0.4

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.
Files changed (58) hide show
  1. package/README.md +16 -207
  2. package/bin/detect.d.ts +1 -0
  3. package/bin/detect.js +62 -0
  4. package/bin/generators.d.ts +18 -0
  5. package/bin/generators.js +193 -0
  6. package/bin/hale-commenting.js +4 -0
  7. package/bin/index.d.ts +2 -0
  8. package/bin/index.js +61 -0
  9. package/bin/onboarding.d.ts +1 -0
  10. package/bin/onboarding.js +170 -0
  11. package/bin/postinstall.d.ts +2 -0
  12. package/bin/postinstall.js +65 -0
  13. package/bin/validators.d.ts +2 -0
  14. package/bin/validators.js +66 -0
  15. package/dist/cli/detect.d.ts +1 -0
  16. package/dist/cli/detect.js +62 -0
  17. package/dist/cli/generators.d.ts +18 -0
  18. package/dist/cli/generators.js +193 -0
  19. package/dist/cli/index.d.ts +2 -0
  20. package/dist/cli/index.js +61 -0
  21. package/dist/cli/onboarding.d.ts +1 -0
  22. package/dist/cli/onboarding.js +170 -0
  23. package/dist/cli/postinstall.d.ts +2 -0
  24. package/dist/cli/postinstall.js +65 -0
  25. package/dist/cli/validators.d.ts +2 -0
  26. package/dist/cli/validators.js +66 -0
  27. package/dist/components/CommentOverlay.d.ts +2 -0
  28. package/dist/components/CommentOverlay.js +101 -0
  29. package/dist/components/CommentPanel.d.ts +6 -0
  30. package/dist/components/CommentPanel.js +334 -0
  31. package/dist/components/CommentPin.d.ts +11 -0
  32. package/dist/components/CommentPin.js +64 -0
  33. package/dist/components/DetailsTab.d.ts +2 -0
  34. package/dist/components/DetailsTab.js +380 -0
  35. package/dist/components/FloatingWidget.d.ts +8 -0
  36. package/dist/components/FloatingWidget.js +128 -0
  37. package/dist/components/JiraTab.d.ts +2 -0
  38. package/dist/components/JiraTab.js +507 -0
  39. package/dist/contexts/CommentContext.d.ts +30 -0
  40. package/dist/contexts/CommentContext.js +891 -0
  41. package/dist/contexts/GitHubAuthContext.d.ts +13 -0
  42. package/dist/contexts/GitHubAuthContext.js +96 -0
  43. package/dist/index.d.ts +10 -97
  44. package/dist/index.js +26 -786
  45. package/dist/services/githubAdapter.d.ts +56 -0
  46. package/dist/services/githubAdapter.js +321 -0
  47. package/dist/types/index.d.ts +25 -0
  48. package/dist/types/index.js +2 -0
  49. package/dist/utils/version.d.ts +1 -0
  50. package/dist/utils/version.js +23 -0
  51. package/package.json +39 -38
  52. package/templates/webpack-middleware.js +226 -0
  53. package/cli/dist/index.js +0 -370
  54. package/cli/dist/index.js.map +0 -1
  55. package/dist/index.d.mts +0 -97
  56. package/dist/index.js.map +0 -1
  57. package/dist/index.mjs +0 -759
  58. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,891 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.useComments = exports.CommentProvider = void 0;
37
+ const React = __importStar(require("react"));
38
+ const githubAdapter_1 = require("../services/githubAdapter");
39
+ const CommentContext = React.createContext(undefined);
40
+ const CommentProvider = ({ children }) => {
41
+ const stripHaleReplyMarkers = (body) => {
42
+ // Remove hidden markers we embed for threading reconstruction
43
+ return body
44
+ .replace(/<!--\s*hale-reply-to:\d+\s*-->\s*\n?/g, '')
45
+ .replace(/<!--\s*hale-reply-to-local\s*-->\s*\n?/g, '')
46
+ .trimEnd();
47
+ };
48
+ const buildGitHubReplyBody = (text, parent) => {
49
+ if (!parent)
50
+ return text;
51
+ if (!parent.githubCommentId) {
52
+ // We can still preserve local threading, but GitHub can't link to a parent comment id we don't have.
53
+ return `${text}\n\n<!-- hale-reply-to-local -->`;
54
+ }
55
+ // Hidden marker so we can reconstruct threading on sync
56
+ const marker = `<!-- hale-reply-to:${parent.githubCommentId} -->`;
57
+ // Light GitHub-like quoting (keeps context without needing true threading)
58
+ const quoted = parent.text
59
+ ? parent.text
60
+ .split('\n')
61
+ .slice(0, 6)
62
+ .map((l) => `> ${l}`)
63
+ .join('\n')
64
+ : '';
65
+ const header = parent.author ? `> Replying to @${parent.author}` : `> Replying to comment`;
66
+ return [marker, header, quoted, '', text].filter(Boolean).join('\n');
67
+ };
68
+ const parseReplyParentFromGitHubBody = (body) => {
69
+ if (!body)
70
+ return undefined;
71
+ const m = body.match(/<!--\s*hale-reply-to:(\d+)\s*-->/);
72
+ if (!m?.[1])
73
+ return undefined;
74
+ const id = Number(m[1]);
75
+ return Number.isNaN(id) ? undefined : id;
76
+ };
77
+ const inferReplyParentFromQuote = (body, candidates) => {
78
+ // GitHub "Quote reply" is flat; it includes a quoted block but no parent id.
79
+ // Heuristic: extract the leading quoted block and find the best-matching prior comment text.
80
+ const cleaned = stripHaleReplyMarkers(body);
81
+ const lines = cleaned.split('\n');
82
+ const quotedLines = [];
83
+ for (const line of lines) {
84
+ const trimmed = line.trimEnd();
85
+ if (trimmed.startsWith('>')) {
86
+ quotedLines.push(trimmed.replace(/^>\s?/, ''));
87
+ continue;
88
+ }
89
+ if (quotedLines.length === 0 && trimmed === '')
90
+ continue;
91
+ break;
92
+ }
93
+ const snippet = quotedLines.join('\n').trim();
94
+ if (snippet.length < 12)
95
+ return undefined;
96
+ let best;
97
+ for (const c of candidates) {
98
+ if (!c.githubCommentId)
99
+ continue;
100
+ const hay = (c.text || '').toLowerCase();
101
+ const needle = snippet.toLowerCase();
102
+ const idx = hay.indexOf(needle);
103
+ if (idx === -1)
104
+ continue;
105
+ const score = needle.length;
106
+ if (!best || score > best.score)
107
+ best = { id: c.githubCommentId, score };
108
+ }
109
+ return best?.id;
110
+ };
111
+ const STORAGE_KEY = 'hale_comment_threads_v1';
112
+ const COMMENTS_ENABLED_KEY = 'hale_comments_enabled_v1';
113
+ const DRAWER_PINNED_OPEN_KEY = 'hale_drawer_pinned_open_v1';
114
+ const FLOATING_WIDGET_MODE_KEY = 'hale_floating_widget_mode_v1';
115
+ const HIDDEN_ISSUES_KEY = 'hale_hidden_issue_numbers_v1';
116
+ const PENDING_CLOSE_ISSUES_KEY = 'hale_pending_close_issue_numbers_v1';
117
+ const readNumberSet = (key) => {
118
+ try {
119
+ const raw = window.localStorage.getItem(key);
120
+ if (!raw)
121
+ return new Set();
122
+ const parsed = JSON.parse(raw);
123
+ if (!Array.isArray(parsed))
124
+ return new Set();
125
+ return new Set(parsed.map((n) => Number(n)).filter((n) => !Number.isNaN(n)));
126
+ }
127
+ catch {
128
+ return new Set();
129
+ }
130
+ };
131
+ const writeNumberSet = (key, set) => {
132
+ try {
133
+ window.localStorage.setItem(key, JSON.stringify(Array.from(set)));
134
+ }
135
+ catch {
136
+ // ignore
137
+ }
138
+ };
139
+ const hiddenIssueNumbersRef = React.useRef(new Set());
140
+ const pendingCloseIssueNumbersRef = React.useRef(new Set());
141
+ const removedThreadIdsRef = React.useRef(new Set());
142
+ React.useEffect(() => {
143
+ hiddenIssueNumbersRef.current = readNumberSet(HIDDEN_ISSUES_KEY);
144
+ pendingCloseIssueNumbersRef.current = readNumberSet(PENDING_CLOSE_ISSUES_KEY);
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ }, []);
147
+ const loadThreads = () => {
148
+ if (typeof window === 'undefined')
149
+ return [];
150
+ const raw = window.localStorage.getItem(STORAGE_KEY);
151
+ if (!raw)
152
+ return [];
153
+ try {
154
+ const parsed = JSON.parse(raw);
155
+ if (!Array.isArray(parsed))
156
+ return [];
157
+ return parsed;
158
+ }
159
+ catch {
160
+ return [];
161
+ }
162
+ };
163
+ const [threads, setThreads] = React.useState(() => loadThreads());
164
+ const [commentsEnabled, setCommentsEnabled] = React.useState(() => {
165
+ try {
166
+ const raw = window.localStorage.getItem(COMMENTS_ENABLED_KEY);
167
+ return raw === 'true';
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ });
173
+ const [selectedThreadId, setSelectedThreadId] = React.useState(null);
174
+ const [drawerPinnedOpen, setDrawerPinnedOpen] = React.useState(() => {
175
+ try {
176
+ const raw = window.localStorage.getItem(DRAWER_PINNED_OPEN_KEY);
177
+ return raw === 'true';
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ });
183
+ const [floatingWidgetMode, setFloatingWidgetMode] = React.useState(() => {
184
+ try {
185
+ const raw = window.localStorage.getItem(FLOATING_WIDGET_MODE_KEY);
186
+ return raw === 'true';
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ });
192
+ const [syncInFlightCount, setSyncInFlightCount] = React.useState(0);
193
+ const isSyncing = syncInFlightCount > 0;
194
+ const syncInFlightByKey = React.useRef(new Map());
195
+ const threadsRef = React.useRef([]);
196
+ React.useEffect(() => {
197
+ threadsRef.current = threads;
198
+ }, [threads]);
199
+ // Persist threads so refreshes don't wipe pins/comments.
200
+ React.useEffect(() => {
201
+ if (typeof window === 'undefined')
202
+ return;
203
+ try {
204
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(threads));
205
+ }
206
+ catch {
207
+ // ignore quota/serialization errors
208
+ }
209
+ }, [threads]);
210
+ React.useEffect(() => {
211
+ try {
212
+ window.localStorage.setItem(COMMENTS_ENABLED_KEY, String(commentsEnabled));
213
+ }
214
+ catch {
215
+ // ignore
216
+ }
217
+ }, [commentsEnabled]);
218
+ React.useEffect(() => {
219
+ try {
220
+ window.localStorage.setItem(DRAWER_PINNED_OPEN_KEY, String(drawerPinnedOpen));
221
+ }
222
+ catch {
223
+ // ignore
224
+ }
225
+ }, [drawerPinnedOpen]);
226
+ React.useEffect(() => {
227
+ try {
228
+ window.localStorage.setItem(FLOATING_WIDGET_MODE_KEY, String(floatingWidgetMode));
229
+ }
230
+ catch {
231
+ // ignore
232
+ }
233
+ }, [floatingWidgetMode]);
234
+ const addThread = (xPercent, yPercent, route, version) => {
235
+ const threadId = `thread-${Date.now()}`;
236
+ const isConfigured = (0, githubAdapter_1.isGitHubConfigured)();
237
+ console.log('📌 addThread called:', {
238
+ threadId,
239
+ route,
240
+ version,
241
+ xPercent: xPercent.toFixed(1),
242
+ yPercent: yPercent.toFixed(1),
243
+ isGitHubConfigured: isConfigured,
244
+ });
245
+ const newThread = {
246
+ id: threadId,
247
+ xPercent,
248
+ yPercent,
249
+ route,
250
+ version,
251
+ comments: [],
252
+ provider: 'github',
253
+ syncStatus: isConfigured ? 'syncing' : 'local',
254
+ status: 'open',
255
+ };
256
+ setThreads((prev) => [...prev, newThread]);
257
+ console.log(`📌 Thread created locally with syncStatus: ${newThread.syncStatus}`);
258
+ // Background sync to GitHub (optimistic UI)
259
+ if (isConfigured) {
260
+ console.log(`🔵 Creating GitHub issue for thread ${threadId}...`);
261
+ githubAdapter_1.githubAdapter
262
+ .createIssue({
263
+ title: `Feedback: ${route}`,
264
+ body: `Thread created from pin at (${xPercent.toFixed(1)}%, ${yPercent.toFixed(1)}%).`,
265
+ route,
266
+ xPercent,
267
+ yPercent,
268
+ version,
269
+ })
270
+ .then((result) => {
271
+ console.log(`🔵 GitHub createIssue response:`, result);
272
+ if (result.success) {
273
+ console.log(`✅ Successfully created GitHub issue #${result.data?.number}`);
274
+ }
275
+ else {
276
+ console.error(`❌ Failed to create GitHub issue:`, result.error);
277
+ }
278
+ // If the user removed the pin before issue creation completed, immediately close the issue and tombstone it.
279
+ if (result.success && result.data?.number && removedThreadIdsRef.current.has(threadId)) {
280
+ const num = result.data.number;
281
+ hiddenIssueNumbersRef.current.add(num);
282
+ writeNumberSet(HIDDEN_ISSUES_KEY, hiddenIssueNumbersRef.current);
283
+ githubAdapter_1.githubAdapter.closeIssue(num).catch(() => undefined);
284
+ removedThreadIdsRef.current.delete(threadId);
285
+ }
286
+ setThreads((prev) => prev.map((t) => t.id === threadId
287
+ ? {
288
+ ...t,
289
+ issueNumber: result.success ? result.data?.number : undefined,
290
+ issueUrl: result.success ? result.data?.html_url : undefined,
291
+ syncStatus: result.success ? 'synced' : 'error',
292
+ syncError: result.success ? undefined : result.error,
293
+ }
294
+ : t));
295
+ console.log(`📌 Thread ${threadId} syncStatus updated to: ${result.success ? 'synced' : 'error'}`);
296
+ })
297
+ .catch((err) => {
298
+ console.error(`❌ Exception during GitHub issue creation:`, err);
299
+ setThreads((prev) => prev.map((t) => (t.id === threadId ? { ...t, syncStatus: 'error', syncError: 'Failed to create issue' } : t)));
300
+ console.log(`📌 Thread ${threadId} syncStatus updated to: error (exception caught)`);
301
+ });
302
+ }
303
+ return threadId;
304
+ };
305
+ const parseCoordsFromIssueBody = (body) => {
306
+ const match = body.match(/Coordinates:\s*`?\(([\d.]+)%?,\s*([\d.]+)%?\)`?/i);
307
+ if (!match)
308
+ return null;
309
+ const x = Number(match[1]);
310
+ const y = Number(match[2]);
311
+ if (Number.isNaN(x) || Number.isNaN(y))
312
+ return null;
313
+ return { xPercent: x, yPercent: y };
314
+ };
315
+ const parseCoordsFromIssueLabels = (issue) => {
316
+ const labels = issue?.labels;
317
+ if (!Array.isArray(labels))
318
+ return null;
319
+ const names = labels
320
+ .map((l) => (typeof l === 'string' ? l : l?.name))
321
+ .filter((n) => typeof n === 'string');
322
+ const coord = names.find((n) => n.startsWith('coords:'));
323
+ if (!coord)
324
+ return null;
325
+ const raw = coord.replace('coords:', '');
326
+ const parts = raw.split(',').map((p) => Number(p.trim()));
327
+ if (parts.length !== 2)
328
+ return null;
329
+ const [x, y] = parts;
330
+ if (Number.isNaN(x) || Number.isNaN(y))
331
+ return null;
332
+ return { xPercent: x, yPercent: y };
333
+ };
334
+ const syncFromGitHub = async (route, version) => {
335
+ if (!(0, githubAdapter_1.isGitHubConfigured)())
336
+ return;
337
+ const key = `${route}::${version ?? ''}`;
338
+ const existing = syncInFlightByKey.current.get(key);
339
+ if (existing) {
340
+ console.log(`⏭️ Sync already in progress for ${key}, skipping`);
341
+ return existing;
342
+ }
343
+ // Skip sync if there are threads actively syncing to prevent race conditions
344
+ const activelySyncingThreads = threadsRef.current.filter((t) => t.route === route && (t.version ?? '1') === (version ?? '1') && t.syncStatus === 'syncing');
345
+ if (activelySyncingThreads.length > 0) {
346
+ console.log(`⏭️ Skipping sync for ${key} - ${activelySyncingThreads.length} thread(s) actively syncing:`, activelySyncingThreads.map(t => t.id));
347
+ return;
348
+ }
349
+ console.log(`🔄 Starting sync for ${key}`);
350
+ const run = (async () => {
351
+ setSyncInFlightCount((c) => c + 1);
352
+ try {
353
+ const issuesResult = await githubAdapter_1.githubAdapter.fetchIssuesForRouteAndVersion(route, version);
354
+ if (!issuesResult.success || !issuesResult.data)
355
+ return;
356
+ const hidden = hiddenIssueNumbersRef.current;
357
+ const issues = issuesResult.data.filter((i) => {
358
+ const num = i?.number;
359
+ if (!num)
360
+ return true;
361
+ return !hidden.has(num);
362
+ });
363
+ // Build thread objects from GitHub issues + issue comments
364
+ const ghThreads = [];
365
+ for (const issue of issues) {
366
+ const issueNumber = issue?.number;
367
+ const issueUrl = issue?.html_url;
368
+ if (!issueNumber)
369
+ continue;
370
+ const coords = parseCoordsFromIssueBody(issue?.body || '') ||
371
+ parseCoordsFromIssueLabels(issue) ||
372
+ { xPercent: 0, yPercent: 0 };
373
+ const commentsResult = await githubAdapter_1.githubAdapter.fetchIssueComments(issueNumber);
374
+ const ghComments = commentsResult.success && commentsResult.data ? commentsResult.data : [];
375
+ const mappedComments = (Array.isArray(ghComments) ? ghComments : []).map((c) => {
376
+ const rawBody = c?.body || '';
377
+ return {
378
+ id: `ghc-${c.id}`,
379
+ githubCommentId: c.id,
380
+ parentGitHubCommentId: parseReplyParentFromGitHubBody(rawBody),
381
+ author: c?.user?.login,
382
+ text: stripHaleReplyMarkers(rawBody),
383
+ createdAt: c?.created_at || new Date().toISOString(),
384
+ };
385
+ });
386
+ // Second pass: infer parent from quoted blocks when no explicit hale marker exists.
387
+ for (const c of mappedComments) {
388
+ if (c.parentGitHubCommentId)
389
+ continue;
390
+ const raw = (Array.isArray(ghComments) ? ghComments : []).find((x) => x?.id === c.githubCommentId)?.body || '';
391
+ const inferred = inferReplyParentFromQuote(raw, mappedComments);
392
+ if (inferred && inferred !== c.githubCommentId) {
393
+ c.parentGitHubCommentId = inferred;
394
+ }
395
+ }
396
+ ghThreads.push({
397
+ id: `gh-${issueNumber}`,
398
+ route,
399
+ version,
400
+ xPercent: coords.xPercent,
401
+ yPercent: coords.yPercent,
402
+ comments: mappedComments,
403
+ issueNumber,
404
+ issueUrl,
405
+ provider: 'github',
406
+ syncStatus: 'synced',
407
+ status: issue?.state === 'closed' ? 'closed' : 'open',
408
+ });
409
+ }
410
+ // Merge: keep local-only comments (those without githubCommentId)
411
+ setThreads((prev) => {
412
+ const prevByIssue = new Map();
413
+ for (const t of prev) {
414
+ if (t.issueNumber)
415
+ prevByIssue.set(t.issueNumber, t);
416
+ }
417
+ const merged = ghThreads.map((gt) => {
418
+ const existing = gt.issueNumber ? prevByIssue.get(gt.issueNumber) : undefined;
419
+ if (!existing)
420
+ return gt;
421
+ const localOnly = existing.comments.filter((c) => !c.githubCommentId);
422
+ const mergedComments = [...gt.comments, ...localOnly];
423
+ return {
424
+ ...gt,
425
+ version: gt.version ?? existing.version,
426
+ xPercent: gt.xPercent || existing.xPercent,
427
+ yPercent: gt.yPercent || existing.yPercent,
428
+ comments: mergedComments,
429
+ };
430
+ });
431
+ // Keep local threads on this route/version that:
432
+ // 1. Don't have an issueNumber yet, OR
433
+ // 2. Are actively syncing (prevents race condition where issue was created but GitHub API hasn't returned it yet)
434
+ const localUnlinked = prev.filter((t) => t.route === route &&
435
+ (t.version ?? '1') === (version ?? '1') &&
436
+ (!t.issueNumber || t.syncStatus === 'syncing'));
437
+ // Remove duplicates: if a thread is both in localUnlinked and merged, prefer the merged version
438
+ const localUnlinkedDeduped = localUnlinked.filter((local) => !merged.some((m) => m.issueNumber && m.issueNumber === local.issueNumber));
439
+ // Preserve threads from other routes/versions unchanged.
440
+ const other = prev.filter((t) => !(t.route === route && (t.version ?? '1') === (version ?? '1')));
441
+ console.log(`🔄 Sync merge for ${key}:`, {
442
+ fromGitHub: ghThreads.length,
443
+ merged: merged.length,
444
+ localUnlinked: localUnlinked.length,
445
+ localUnlinkedDeduped: localUnlinkedDeduped.length,
446
+ other: other.length,
447
+ total: other.length + localUnlinkedDeduped.length + merged.length,
448
+ previousTotal: prev.length
449
+ });
450
+ return [...other, ...localUnlinkedDeduped, ...merged];
451
+ });
452
+ }
453
+ finally {
454
+ setSyncInFlightCount((c) => Math.max(0, c - 1));
455
+ syncInFlightByKey.current.delete(key);
456
+ }
457
+ })();
458
+ syncInFlightByKey.current.set(key, run);
459
+ return run;
460
+ };
461
+ const addReply = (threadId, text, parentCommentId) => {
462
+ const author = (0, githubAdapter_1.getStoredUser)()?.login;
463
+ const createdAt = new Date().toISOString();
464
+ const localCommentId = `comment-${Date.now()}`;
465
+ const threadSnapshot = threadsRef.current.find((t) => t.id === threadId);
466
+ const parent = parentCommentId
467
+ ? threadSnapshot?.comments.find((c) => c.id === parentCommentId)
468
+ : undefined;
469
+ // Optimistically add locally
470
+ setThreads((prev) => prev.map((thread) => {
471
+ if (thread.id !== threadId)
472
+ return thread;
473
+ const newComment = {
474
+ id: localCommentId,
475
+ author,
476
+ text,
477
+ createdAt,
478
+ parentCommentId,
479
+ parentGitHubCommentId: parent?.githubCommentId,
480
+ };
481
+ return {
482
+ ...thread,
483
+ comments: [...thread.comments, newComment],
484
+ };
485
+ }));
486
+ // Background sync to GitHub issue comments (if available)
487
+ const thread = threadsRef.current.find((t) => t.id === threadId);
488
+ const issueNumber = thread?.issueNumber;
489
+ if (!(0, githubAdapter_1.isGitHubConfigured)() || !thread)
490
+ return;
491
+ // If the thread hasn't finished creating its issue yet, create it now, then backfill any local-only comments.
492
+ const ensureIssueAndBackfill = async () => {
493
+ try {
494
+ setThreads((prev) => prev.map((t) => (t.id === threadId ? { ...t, syncStatus: 'syncing', syncError: undefined } : t)));
495
+ let ensuredIssueNumber = thread.issueNumber;
496
+ let ensuredIssueUrl = thread.issueUrl;
497
+ if (!ensuredIssueNumber) {
498
+ const created = await githubAdapter_1.githubAdapter.createIssue({
499
+ title: `Feedback: ${thread.route}`,
500
+ body: `Thread created from pin at (${thread.xPercent.toFixed(1)}%, ${thread.yPercent.toFixed(1)}%).`,
501
+ route: thread.route,
502
+ xPercent: thread.xPercent,
503
+ yPercent: thread.yPercent,
504
+ version: thread.version,
505
+ });
506
+ if (!created.success || !created.data?.number) {
507
+ throw new Error(created.error || 'Failed to create issue');
508
+ }
509
+ ensuredIssueNumber = created.data.number;
510
+ ensuredIssueUrl = created.data.html_url;
511
+ setThreads((prev) => prev.map((t) => t.id === threadId
512
+ ? {
513
+ ...t,
514
+ issueNumber: ensuredIssueNumber,
515
+ issueUrl: ensuredIssueUrl,
516
+ syncStatus: 'syncing', // Keep syncing while backfilling comments
517
+ syncError: undefined,
518
+ }
519
+ : t));
520
+ }
521
+ // Backfill all comments that don't yet have a GitHub comment id (including the one we just added),
522
+ // ensuring parents are synced before children so replies can reference a parent GitHub comment id.
523
+ const latest = threadsRef.current.find((t) => t.id === threadId);
524
+ const comments = latest?.comments || [];
525
+ const pending = comments.filter((c) => !c.githubCommentId);
526
+ const pendingIds = new Set(pending.map((c) => c.id));
527
+ const createdGitHubIdsByLocalId = new Map();
528
+ const canSync = (c) => {
529
+ if (!c.parentCommentId)
530
+ return true;
531
+ const parent = comments.find((pc) => pc.id === c.parentCommentId);
532
+ if (!parent)
533
+ return true;
534
+ if (parent.githubCommentId)
535
+ return true;
536
+ if (createdGitHubIdsByLocalId.has(parent.id))
537
+ return true;
538
+ // if parent isn't pending, nothing we can do
539
+ if (!pendingIds.has(parent.id))
540
+ return true;
541
+ return false;
542
+ };
543
+ const queue = [...pending];
544
+ let guard = 0;
545
+ while (queue.length > 0 && guard < 10000) {
546
+ guard++;
547
+ const idx = queue.findIndex(canSync);
548
+ if (idx === -1)
549
+ break;
550
+ const c = queue.splice(idx, 1)[0];
551
+ const parentForBody = c.parentCommentId ? comments.find((pc) => pc.id === c.parentCommentId) : undefined;
552
+ const resolvedParentGitHubId = parentForBody?.githubCommentId ?? (parentForBody ? createdGitHubIdsByLocalId.get(parentForBody.id) : undefined);
553
+ // keep local linkage for display; set parentGitHubCommentId once we know it
554
+ if (c.parentCommentId && resolvedParentGitHubId) {
555
+ setThreads((prev) => prev.map((t) => {
556
+ if (t.id !== threadId)
557
+ return t;
558
+ return {
559
+ ...t,
560
+ comments: t.comments.map((cc) => cc.id === c.id ? { ...cc, parentGitHubCommentId: resolvedParentGitHubId } : cc),
561
+ };
562
+ }));
563
+ }
564
+ const body = buildGitHubReplyBody(c.text, resolvedParentGitHubId ? { ...parentForBody, githubCommentId: resolvedParentGitHubId } : parentForBody);
565
+ const res = await githubAdapter_1.githubAdapter.createComment(ensuredIssueNumber, body);
566
+ if (!res.success || !res.data?.id) {
567
+ throw new Error(res.error || 'Failed to create GitHub comment');
568
+ }
569
+ const newId = res.data.id;
570
+ createdGitHubIdsByLocalId.set(c.id, newId);
571
+ setThreads((prev) => prev.map((t) => {
572
+ if (t.id !== threadId)
573
+ return t;
574
+ return {
575
+ ...t,
576
+ comments: t.comments.map((cc) => (cc.id === c.id ? { ...cc, githubCommentId: newId } : cc)),
577
+ };
578
+ }));
579
+ }
580
+ setThreads((prev) => prev.map((t) => (t.id === threadId ? { ...t, syncStatus: 'synced', syncError: undefined } : t)));
581
+ }
582
+ catch (e) {
583
+ setThreads((prev) => prev.map((t) => t.id === threadId
584
+ ? { ...t, syncStatus: 'pending', syncError: e?.message || 'Failed to sync reply' }
585
+ : t));
586
+ }
587
+ };
588
+ if (issueNumber) {
589
+ // If replying to a parent that hasn't synced to GitHub yet, backfill first so we can preserve threading markers.
590
+ if (parentCommentId && parent && !parent.githubCommentId) {
591
+ void ensureIssueAndBackfill();
592
+ return;
593
+ }
594
+ const body = buildGitHubReplyBody(text, parent);
595
+ githubAdapter_1.githubAdapter
596
+ .createComment(issueNumber, body)
597
+ .then((result) => {
598
+ if (!result.success)
599
+ throw new Error(result.error || 'Failed to create GitHub comment');
600
+ const githubCommentId = result.data?.id;
601
+ if (!githubCommentId)
602
+ throw new Error('No GitHub comment id returned');
603
+ setThreads((prev) => prev.map((t) => {
604
+ if (t.id !== threadId)
605
+ return t;
606
+ return {
607
+ ...t,
608
+ syncStatus: 'synced',
609
+ syncError: undefined,
610
+ comments: t.comments.map((c) => c.id === localCommentId ? { ...c, githubCommentId } : c),
611
+ };
612
+ }));
613
+ })
614
+ .catch(() => ensureIssueAndBackfill());
615
+ }
616
+ else {
617
+ void ensureIssueAndBackfill();
618
+ }
619
+ };
620
+ const updateComment = (threadId, commentId, text) => {
621
+ const thread = threadsRef.current.find((t) => t.id === threadId);
622
+ const issueNumber = thread?.issueNumber;
623
+ const existingComment = thread?.comments.find((c) => c.id === commentId);
624
+ const githubCommentId = existingComment?.githubCommentId;
625
+ setThreads((prev) => prev.map((thread) => {
626
+ if (thread.id === threadId) {
627
+ return {
628
+ ...thread,
629
+ comments: thread.comments.map((comment) => comment.id === commentId ? { ...comment, text } : comment),
630
+ };
631
+ }
632
+ return thread;
633
+ }));
634
+ if ((0, githubAdapter_1.isGitHubConfigured)() && issueNumber && githubCommentId) {
635
+ githubAdapter_1.githubAdapter.updateComment(githubCommentId, text).then((result) => {
636
+ if (result.success) {
637
+ setThreads((prev) => prev.map((t) => (t.id === threadId ? { ...t, syncStatus: 'synced', syncError: undefined } : t)));
638
+ return;
639
+ }
640
+ setThreads((prev) => prev.map((t) => t.id === threadId ? { ...t, syncStatus: 'pending', syncError: result.error || 'Failed to update comment' } : t));
641
+ });
642
+ }
643
+ };
644
+ const deleteComment = (threadId, commentId) => {
645
+ const thread = threadsRef.current.find((t) => t.id === threadId);
646
+ const issueNumber = thread?.issueNumber;
647
+ const existingComment = thread?.comments.find((c) => c.id === commentId);
648
+ const githubCommentId = existingComment?.githubCommentId;
649
+ console.log('🗑️ deleteComment called:', {
650
+ threadId,
651
+ commentId,
652
+ issueNumber,
653
+ githubCommentId,
654
+ hasExistingComment: !!existingComment,
655
+ isGitHubConfigured: (0, githubAdapter_1.isGitHubConfigured)(),
656
+ });
657
+ // Remove from local state immediately (optimistic delete)
658
+ setThreads((prev) => prev.map((thread) => {
659
+ if (thread.id === threadId) {
660
+ return {
661
+ ...thread,
662
+ comments: thread.comments.filter((comment) => comment.id !== commentId),
663
+ };
664
+ }
665
+ return thread;
666
+ }));
667
+ // Attempt GitHub deletion if applicable
668
+ if ((0, githubAdapter_1.isGitHubConfigured)() && issueNumber && githubCommentId) {
669
+ console.log(`🔵 Attempting to delete GitHub comment #${githubCommentId} on issue #${issueNumber}`);
670
+ githubAdapter_1.githubAdapter.deleteComment(githubCommentId).then((result) => {
671
+ if (result.success) {
672
+ console.log(`✅ Successfully deleted GitHub comment #${githubCommentId}`);
673
+ setThreads((prev) => prev.map((t) => (t.id === threadId ? { ...t, syncStatus: 'synced', syncError: undefined } : t)));
674
+ return;
675
+ }
676
+ console.error(`❌ Failed to delete GitHub comment #${githubCommentId}:`, result.error);
677
+ // Restore comment if delete failed
678
+ if (existingComment) {
679
+ console.warn(`⚠️ Restoring comment locally due to GitHub deletion failure`);
680
+ setThreads((prev) => prev.map((t) => {
681
+ if (t.id !== threadId)
682
+ return t;
683
+ return {
684
+ ...t,
685
+ syncStatus: 'error',
686
+ syncError: result.error || 'Failed to delete comment on GitHub',
687
+ comments: [...t.comments, existingComment],
688
+ };
689
+ }));
690
+ }
691
+ }).catch((err) => {
692
+ console.error(`❌ Exception during GitHub comment deletion:`, err);
693
+ // Restore comment on exception
694
+ if (existingComment) {
695
+ setThreads((prev) => prev.map((t) => {
696
+ if (t.id !== threadId)
697
+ return t;
698
+ return {
699
+ ...t,
700
+ syncStatus: 'error',
701
+ syncError: 'Exception during GitHub comment deletion',
702
+ comments: [...t.comments, existingComment],
703
+ };
704
+ }));
705
+ }
706
+ });
707
+ }
708
+ else {
709
+ console.log(`ℹ️ GitHub deletion skipped:`, {
710
+ reason: !(0, githubAdapter_1.isGitHubConfigured)()
711
+ ? 'GitHub not configured'
712
+ : !issueNumber
713
+ ? 'No issue number'
714
+ : !githubCommentId
715
+ ? 'Comment not synced to GitHub yet'
716
+ : 'Unknown',
717
+ });
718
+ }
719
+ };
720
+ const closeThread = (threadId) => {
721
+ const thread = threadsRef.current.find((t) => t.id === threadId);
722
+ const issueNumber = thread?.issueNumber;
723
+ console.log('🔒 closeThread called:', { threadId, issueNumber });
724
+ // Mark thread as closed locally
725
+ setThreads((prev) => prev.map((t) => (t.id === threadId ? { ...t, status: 'closed' } : t)));
726
+ // Keep the thread selected so the UI can switch to a "Reopen" state (GitHub-like)
727
+ // Sync close to GitHub
728
+ if ((0, githubAdapter_1.isGitHubConfigured)() && issueNumber) {
729
+ console.log(`🔵 Closing GitHub issue #${issueNumber}...`);
730
+ githubAdapter_1.githubAdapter.closeIssue(issueNumber).then((result) => {
731
+ if (result.success) {
732
+ console.log(`✅ Successfully closed GitHub issue #${issueNumber}`);
733
+ setThreads((prev) => prev.map((t) => (t.id === threadId ? { ...t, syncStatus: 'synced', syncError: undefined } : t)));
734
+ }
735
+ else {
736
+ console.error(`❌ Failed to close GitHub issue #${issueNumber}:`, result.error);
737
+ setThreads((prev) => prev.map((t) => t.id === threadId
738
+ ? { ...t, syncStatus: 'error', syncError: result.error || 'Failed to close issue' }
739
+ : t));
740
+ }
741
+ });
742
+ }
743
+ };
744
+ const reopenThread = (threadId) => {
745
+ const thread = threadsRef.current.find((t) => t.id === threadId);
746
+ const issueNumber = thread?.issueNumber;
747
+ console.log('🔓 reopenThread called:', { threadId, issueNumber });
748
+ // Mark thread as open locally
749
+ setThreads((prev) => prev.map((t) => (t.id === threadId ? { ...t, status: 'open' } : t)));
750
+ // Sync reopen to GitHub
751
+ if ((0, githubAdapter_1.isGitHubConfigured)() && issueNumber) {
752
+ console.log(`🔵 Reopening GitHub issue #${issueNumber}...`);
753
+ githubAdapter_1.githubAdapter.reopenIssue(issueNumber).then((result) => {
754
+ if (result.success) {
755
+ console.log(`✅ Successfully reopened GitHub issue #${issueNumber}`);
756
+ setThreads((prev) => prev.map((t) => (t.id === threadId ? { ...t, syncStatus: 'synced', syncError: undefined } : t)));
757
+ }
758
+ else {
759
+ console.error(`❌ Failed to reopen GitHub issue #${issueNumber}:`, result.error);
760
+ setThreads((prev) => prev.map((t) => t.id === threadId
761
+ ? { ...t, syncStatus: 'error', syncError: result.error || 'Failed to reopen issue' }
762
+ : t));
763
+ }
764
+ });
765
+ }
766
+ };
767
+ const removePin = (threadId) => {
768
+ const thread = threadsRef.current.find((t) => t.id === threadId);
769
+ const issueNumber = thread?.issueNumber;
770
+ // Remove locally immediately.
771
+ setThreads((prev) => prev.filter((t) => t.id !== threadId));
772
+ if (selectedThreadId === threadId)
773
+ setSelectedThreadId(null);
774
+ if (!(0, githubAdapter_1.isGitHubConfigured)())
775
+ return;
776
+ if (!issueNumber) {
777
+ // Issue may still be creating; mark so we can close it once we get the number.
778
+ removedThreadIdsRef.current.add(threadId);
779
+ return;
780
+ }
781
+ // Prevent re-appearing on sync even if close is slow/fails.
782
+ hiddenIssueNumbersRef.current.add(issueNumber);
783
+ writeNumberSet(HIDDEN_ISSUES_KEY, hiddenIssueNumbersRef.current);
784
+ pendingCloseIssueNumbersRef.current.add(issueNumber);
785
+ writeNumberSet(PENDING_CLOSE_ISSUES_KEY, pendingCloseIssueNumbersRef.current);
786
+ githubAdapter_1.githubAdapter.closeIssue(issueNumber).then((result) => {
787
+ if (result.success) {
788
+ pendingCloseIssueNumbersRef.current.delete(issueNumber);
789
+ writeNumberSet(PENDING_CLOSE_ISSUES_KEY, pendingCloseIssueNumbersRef.current);
790
+ }
791
+ });
792
+ };
793
+ const getThreadsForRoute = (route, version) => {
794
+ return threads.filter((thread) => thread.route === route && (!version || (thread.version ?? '1') === version));
795
+ };
796
+ const retrySync = async () => {
797
+ if (!(0, githubAdapter_1.isGitHubConfigured)())
798
+ return;
799
+ setSyncInFlightCount((c) => c + 1);
800
+ try {
801
+ const current = threadsRef.current;
802
+ // First, create issues for threads that don't have an issueNumber yet.
803
+ for (const t of current) {
804
+ if (t.issueNumber)
805
+ continue;
806
+ if (t.syncStatus !== 'error' && t.syncStatus !== 'pending' && t.syncStatus !== 'syncing' && t.syncStatus !== 'local')
807
+ continue;
808
+ setThreads((prev) => prev.map((x) => (x.id === t.id ? { ...x, syncStatus: 'syncing', syncError: undefined } : x)));
809
+ const created = await githubAdapter_1.githubAdapter.createIssue({
810
+ title: `Feedback: ${t.route}`,
811
+ body: `Thread created from pin at (${t.xPercent.toFixed(1)}%, ${t.yPercent.toFixed(1)}%).`,
812
+ route: t.route,
813
+ xPercent: t.xPercent,
814
+ yPercent: t.yPercent,
815
+ version: t.version,
816
+ });
817
+ if (created.success && created.data?.number) {
818
+ setThreads((prev) => prev.map((x) => x.id === t.id
819
+ ? { ...x, issueNumber: created.data?.number, issueUrl: created.data?.html_url, syncStatus: 'synced', syncError: undefined }
820
+ : x));
821
+ }
822
+ else {
823
+ setThreads((prev) => prev.map((x) => (x.id === t.id ? { ...x, syncStatus: 'error', syncError: created.error || 'Failed to create issue' } : x)));
824
+ }
825
+ }
826
+ // Then, push any local-only comments (no githubCommentId) for threads with an issueNumber.
827
+ const afterIssues = threadsRef.current;
828
+ for (const t of afterIssues) {
829
+ if (!t.issueNumber)
830
+ continue;
831
+ const localOnly = t.comments.filter((c) => !c.githubCommentId);
832
+ if (localOnly.length === 0)
833
+ continue;
834
+ setThreads((prev) => prev.map((x) => (x.id === t.id ? { ...x, syncStatus: 'syncing', syncError: undefined } : x)));
835
+ for (const c of localOnly) {
836
+ const res = await githubAdapter_1.githubAdapter.createComment(t.issueNumber, c.text);
837
+ if (res.success && res.data?.id) {
838
+ const newId = res.data.id;
839
+ setThreads((prev) => prev.map((x) => x.id === t.id
840
+ ? {
841
+ ...x,
842
+ comments: x.comments.map((cc) => (cc.id === c.id ? { ...cc, githubCommentId: newId } : cc)),
843
+ }
844
+ : x));
845
+ }
846
+ else {
847
+ setThreads((prev) => prev.map((x) => (x.id === t.id ? { ...x, syncStatus: 'pending', syncError: res.error || 'Failed to sync comment' } : x)));
848
+ }
849
+ }
850
+ setThreads((prev) => prev.map((x) => (x.id === t.id ? { ...x, syncStatus: 'synced', syncError: undefined } : x)));
851
+ }
852
+ }
853
+ finally {
854
+ setSyncInFlightCount((c) => Math.max(0, c - 1));
855
+ }
856
+ };
857
+ const hasPendingSync = threads.some((t) => t.syncStatus === 'pending' || t.syncStatus === 'error');
858
+ const value = {
859
+ threads,
860
+ commentsEnabled,
861
+ setCommentsEnabled,
862
+ drawerPinnedOpen,
863
+ setDrawerPinnedOpen,
864
+ floatingWidgetMode,
865
+ setFloatingWidgetMode,
866
+ addThread,
867
+ addReply,
868
+ syncFromGitHub,
869
+ retrySync,
870
+ isSyncing,
871
+ hasPendingSync,
872
+ updateComment,
873
+ deleteComment,
874
+ closeThread,
875
+ reopenThread,
876
+ removePin,
877
+ getThreadsForRoute,
878
+ selectedThreadId,
879
+ setSelectedThreadId,
880
+ };
881
+ return React.createElement(CommentContext.Provider, { value: value }, children);
882
+ };
883
+ exports.CommentProvider = CommentProvider;
884
+ const useComments = () => {
885
+ const context = React.useContext(CommentContext);
886
+ if (!context) {
887
+ throw new Error('useComments must be used within a CommentProvider');
888
+ }
889
+ return context;
890
+ };
891
+ exports.useComments = useComments;