hale-commenting-system 2.2.0 → 2.2.2

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