@in-the-loop-labs/pair-review 1.0.0

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 (91) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +371 -0
  3. package/bin/git-diff-lines +146 -0
  4. package/bin/pair-review.js +49 -0
  5. package/package.json +71 -0
  6. package/public/css/ai-summary-modal.css +183 -0
  7. package/public/css/pr.css +8698 -0
  8. package/public/css/repo-settings.css +891 -0
  9. package/public/css/styles.css +479 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +1104 -0
  12. package/public/js/components/AIPanel.js +1639 -0
  13. package/public/js/components/AISummaryModal.js +278 -0
  14. package/public/js/components/AnalysisConfigModal.js +684 -0
  15. package/public/js/components/ConfirmDialog.js +227 -0
  16. package/public/js/components/PreviewModal.js +344 -0
  17. package/public/js/components/ProgressModal.js +678 -0
  18. package/public/js/components/ReviewModal.js +531 -0
  19. package/public/js/components/SplitButton.js +382 -0
  20. package/public/js/components/StatusIndicator.js +265 -0
  21. package/public/js/components/SuggestionNavigator.js +489 -0
  22. package/public/js/components/Toast.js +166 -0
  23. package/public/js/local.js +1580 -0
  24. package/public/js/modules/analysis-history.js +940 -0
  25. package/public/js/modules/comment-manager.js +643 -0
  26. package/public/js/modules/diff-renderer.js +585 -0
  27. package/public/js/modules/file-comment-manager.js +1242 -0
  28. package/public/js/modules/gap-coordinates.js +190 -0
  29. package/public/js/modules/hunk-parser.js +358 -0
  30. package/public/js/modules/line-tracker.js +386 -0
  31. package/public/js/modules/panel-resizer.js +228 -0
  32. package/public/js/modules/storage-cleanup.js +36 -0
  33. package/public/js/modules/suggestion-manager.js +692 -0
  34. package/public/js/pr.js +3503 -0
  35. package/public/js/repo-settings.js +691 -0
  36. package/public/js/utils/file-order.js +87 -0
  37. package/public/js/utils/markdown.js +97 -0
  38. package/public/js/utils/suggestion-ui.js +55 -0
  39. package/public/js/utils/tier-icons.js +25 -0
  40. package/public/local.html +460 -0
  41. package/public/pr.html +329 -0
  42. package/public/repo-settings.html +243 -0
  43. package/src/ai/analyzer.js +2592 -0
  44. package/src/ai/claude-cli.js +153 -0
  45. package/src/ai/claude-provider.js +261 -0
  46. package/src/ai/codex-provider.js +361 -0
  47. package/src/ai/copilot-provider.js +345 -0
  48. package/src/ai/gemini-provider.js +375 -0
  49. package/src/ai/index.js +47 -0
  50. package/src/ai/prompts/baseline/_meta.json +14 -0
  51. package/src/ai/prompts/baseline/level1/balanced.js +239 -0
  52. package/src/ai/prompts/baseline/level1/fast.js +194 -0
  53. package/src/ai/prompts/baseline/level1/thorough.js +319 -0
  54. package/src/ai/prompts/baseline/level2/balanced.js +248 -0
  55. package/src/ai/prompts/baseline/level2/fast.js +201 -0
  56. package/src/ai/prompts/baseline/level2/thorough.js +367 -0
  57. package/src/ai/prompts/baseline/level3/balanced.js +280 -0
  58. package/src/ai/prompts/baseline/level3/fast.js +220 -0
  59. package/src/ai/prompts/baseline/level3/thorough.js +459 -0
  60. package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
  61. package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
  62. package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
  63. package/src/ai/prompts/config.js +52 -0
  64. package/src/ai/prompts/index.js +267 -0
  65. package/src/ai/prompts/shared/diff-instructions.js +50 -0
  66. package/src/ai/prompts/shared/output-schema.js +179 -0
  67. package/src/ai/prompts/shared/valid-files.js +37 -0
  68. package/src/ai/provider.js +260 -0
  69. package/src/config.js +139 -0
  70. package/src/database.js +2284 -0
  71. package/src/git/gitattributes.js +207 -0
  72. package/src/git/worktree.js +688 -0
  73. package/src/github/client.js +893 -0
  74. package/src/github/parser.js +247 -0
  75. package/src/local-review.js +691 -0
  76. package/src/main.js +987 -0
  77. package/src/routes/analysis.js +897 -0
  78. package/src/routes/comments.js +534 -0
  79. package/src/routes/config.js +250 -0
  80. package/src/routes/local.js +1728 -0
  81. package/src/routes/pr.js +1164 -0
  82. package/src/routes/shared.js +218 -0
  83. package/src/routes/worktrees.js +500 -0
  84. package/src/server.js +295 -0
  85. package/src/utils/diff-annotator.js +414 -0
  86. package/src/utils/instructions.js +33 -0
  87. package/src/utils/json-extractor.js +107 -0
  88. package/src/utils/line-validation.js +183 -0
  89. package/src/utils/logger.js +142 -0
  90. package/src/utils/paths.js +161 -0
  91. package/src/utils/stats-calculator.js +86 -0
@@ -0,0 +1,1242 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * FileCommentManager - File-level comment UI handling
4
+ * Handles file-level comments zone rendering, forms, and interactions.
5
+ */
6
+
7
+ class FileCommentManager {
8
+ // Category to emoji mapping for formatting adopted comments (matches SuggestionManager)
9
+ static CATEGORY_EMOJI_MAP = {
10
+ 'bug': '\u{1F41B}', // bug
11
+ 'improvement': '\u{1F4A1}', // lightbulb
12
+ 'suggestion': '\u{1F4AD}', // thought balloon
13
+ 'design': '\u{1F3D7}', // building construction
14
+ 'performance': '\u{1F680}', // rocket
15
+ 'security': '\u{1F512}', // lock
16
+ 'refactor': '\u{1F527}', // wrench
17
+ 'documentation': '\u{1F4DD}', // memo
18
+ 'test': '\u{2705}', // white heavy check mark
19
+ 'style': '\u{1F3A8}', // artist palette
20
+ 'chore': '\u{1F9F9}', // broom
21
+ 'feat': '\u{2728}', // sparkles
22
+ 'fix': '\u{1F527}' // wrench
23
+ };
24
+
25
+ constructor(prManagerRef) {
26
+ // Reference to parent PRManager for API calls and state access
27
+ this.prManager = prManagerRef;
28
+ // Track file-level comments by file path
29
+ this.fileComments = new Map();
30
+ }
31
+
32
+ /**
33
+ * Get emoji for suggestion category
34
+ * @param {string} category - Category name
35
+ * @returns {string} Emoji character
36
+ */
37
+ getCategoryEmoji(category) {
38
+ return FileCommentManager.CATEGORY_EMOJI_MAP[category] || '\u{1F4AC}';
39
+ }
40
+
41
+ /**
42
+ * Format adopted comment text with emoji and category prefix
43
+ * @param {string} text - Comment text
44
+ * @param {string} category - Category name
45
+ * @returns {string} Formatted text
46
+ */
47
+ formatAdoptedComment(text, category) {
48
+ if (!category) {
49
+ return text;
50
+ }
51
+ const emoji = this.getCategoryEmoji(category);
52
+ // Properly capitalize hyphenated categories (e.g., "code-style" -> "Code Style")
53
+ const capitalizedCategory = category
54
+ .split('-')
55
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
56
+ .join(' ');
57
+ return `${emoji} **${capitalizedCategory}**: ${text}`;
58
+ }
59
+
60
+ /**
61
+ * Get the appropriate API endpoint and request body for file-level comments
62
+ * @private
63
+ * @param {string} operation - Operation type: 'create', 'update', 'delete'
64
+ * @param {Object} options - Options object with commentId, file, body, etc.
65
+ * @returns {Object} Object with endpoint and requestBody
66
+ */
67
+ _getFileCommentEndpoint(operation, options = {}) {
68
+ const reviewId = this.prManager?.currentPR?.id;
69
+ const reviewType = this.prManager?.currentPR?.reviewType;
70
+ const headSha = this.prManager?.currentPR?.head_sha;
71
+ const isLocal = reviewType === 'local';
72
+
73
+ let endpoint;
74
+ let requestBody = null;
75
+
76
+ switch (operation) {
77
+ case 'create':
78
+ endpoint = isLocal
79
+ ? `/api/local/${reviewId}/file-comment`
80
+ : '/api/file-comment';
81
+
82
+ requestBody = isLocal
83
+ ? {
84
+ file: options.file,
85
+ body: options.body,
86
+ parent_id: options.parent_id,
87
+ type: options.type,
88
+ title: options.title
89
+ }
90
+ : {
91
+ review_id: reviewId,
92
+ file: options.file,
93
+ body: options.body,
94
+ commit_sha: headSha,
95
+ parent_id: options.parent_id,
96
+ type: options.type,
97
+ title: options.title
98
+ };
99
+ break;
100
+
101
+ case 'update':
102
+ endpoint = isLocal
103
+ ? `/api/local/${reviewId}/file-comment/${options.commentId}`
104
+ : `/api/user-comment/${options.commentId}`;
105
+
106
+ requestBody = { body: options.body };
107
+ break;
108
+
109
+ case 'delete':
110
+ endpoint = isLocal
111
+ ? `/api/local/${reviewId}/file-comment/${options.commentId}`
112
+ : `/api/user-comment/${options.commentId}`;
113
+
114
+ // No body needed for DELETE
115
+ break;
116
+
117
+ default:
118
+ throw new Error(`Unknown operation: ${operation}`);
119
+ }
120
+
121
+ return { endpoint, requestBody };
122
+ }
123
+
124
+ /**
125
+ * Create the file comments zone element for a file
126
+ * File comments are always visible (no collapsible behavior).
127
+ * The comment icon button in the file header directly adds a new comment.
128
+ * @param {string} fileName - The file path
129
+ * @returns {HTMLElement} The file comments zone element
130
+ */
131
+ createFileCommentsZone(fileName) {
132
+ const zone = document.createElement('div');
133
+ zone.className = 'file-comments-zone';
134
+ zone.dataset.fileName = fileName;
135
+
136
+ // Comments container (no header with toggle/add buttons - always visible)
137
+ const container = document.createElement('div');
138
+ container.className = 'file-comments-container';
139
+
140
+ zone.appendChild(container);
141
+
142
+ return zone;
143
+ }
144
+
145
+
146
+ /**
147
+ * Show the comment form for a file
148
+ * @param {HTMLElement} zone - The file comments zone
149
+ * @param {string} fileName - The file path
150
+ */
151
+ showCommentForm(zone, fileName) {
152
+ const container = zone.querySelector('.file-comments-container');
153
+
154
+ // Close any existing form in this zone
155
+ const existingForm = container.querySelector('.file-comment-form');
156
+ if (existingForm) {
157
+ existingForm.remove();
158
+ }
159
+
160
+ // Create form
161
+ const form = document.createElement('div');
162
+ form.className = 'file-comment-form';
163
+ form.innerHTML = `
164
+ <div class="file-comment-form-header">
165
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
166
+ <path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5z"/>
167
+ </svg>
168
+ <label>Add file-level comment</label>
169
+ </div>
170
+ <textarea
171
+ class="file-comment-textarea"
172
+ placeholder="Write a comment about this file... (Ctrl+Enter to save)"
173
+ data-file="${window.escapeHtmlAttribute(fileName)}"
174
+ ></textarea>
175
+ <div class="file-comment-form-footer">
176
+ <button class="file-comment-form-btn submit submit-btn" disabled>Save</button>
177
+ <button class="file-comment-form-btn cancel cancel-btn">Cancel</button>
178
+ </div>
179
+ `;
180
+
181
+ container.appendChild(form);
182
+
183
+ // Get elements
184
+ const textarea = form.querySelector('.file-comment-textarea');
185
+ const submitBtn = form.querySelector('.submit-btn');
186
+ const cancelBtn = form.querySelector('.cancel-btn');
187
+
188
+ // Focus textarea
189
+ textarea.focus();
190
+
191
+ // Focus/blur for styling
192
+ textarea.addEventListener('focus', () => form.classList.add('focused'));
193
+ textarea.addEventListener('blur', () => form.classList.remove('focused'));
194
+
195
+ // Enable/disable submit based on content
196
+ textarea.addEventListener('input', () => {
197
+ submitBtn.disabled = !textarea.value.trim();
198
+ });
199
+
200
+ // Keyboard shortcuts
201
+ textarea.addEventListener('keydown', (e) => {
202
+ if (e.key === 'Escape') {
203
+ this.hideCommentForm(zone);
204
+ } else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && textarea.value.trim()) {
205
+ this.saveFileComment(zone, fileName, textarea.value.trim());
206
+ }
207
+ });
208
+
209
+ // Cancel button
210
+ cancelBtn.addEventListener('click', () => this.hideCommentForm(zone));
211
+
212
+ // Submit button
213
+ submitBtn.addEventListener('click', () => {
214
+ if (textarea.value.trim()) {
215
+ this.saveFileComment(zone, fileName, textarea.value.trim());
216
+ }
217
+ });
218
+
219
+ }
220
+
221
+ /**
222
+ * Hide the comment form
223
+ * @param {HTMLElement} zone - The file comments zone
224
+ */
225
+ hideCommentForm(zone) {
226
+ const container = zone.querySelector('.file-comments-container');
227
+ const form = container.querySelector('.file-comment-form');
228
+
229
+ if (form) {
230
+ form.remove();
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Save a file-level comment
236
+ * @param {HTMLElement} zone - The file comments zone
237
+ * @param {string} fileName - The file path
238
+ * @param {string} body - The comment body
239
+ */
240
+ async saveFileComment(zone, fileName, body) {
241
+ try {
242
+ const { endpoint, requestBody } = this._getFileCommentEndpoint('create', {
243
+ file: fileName,
244
+ body: body
245
+ });
246
+
247
+ const response = await fetch(endpoint, {
248
+ method: 'POST',
249
+ headers: {
250
+ 'Content-Type': 'application/json'
251
+ },
252
+ body: JSON.stringify(requestBody)
253
+ });
254
+
255
+ if (!response.ok) {
256
+ throw new Error('Failed to save file-level comment');
257
+ }
258
+
259
+ const result = await response.json();
260
+
261
+ // Build comment object
262
+ const commentData = {
263
+ id: result.commentId,
264
+ file: fileName,
265
+ body: body,
266
+ source: 'user',
267
+ is_file_level: 1,
268
+ created_at: new Date().toISOString()
269
+ };
270
+
271
+ // Display the new comment
272
+ this.displayUserComment(zone, commentData);
273
+
274
+ // Hide the form
275
+ this.hideCommentForm(zone);
276
+
277
+ // Update count badge
278
+ this.updateCommentCount(zone);
279
+
280
+ // Notify AI Panel if available
281
+ if (window.aiPanel?.addComment) {
282
+ window.aiPanel.addComment(commentData);
283
+ }
284
+
285
+ // Update parent comment count
286
+ if (this.prManager?.updateCommentCount) {
287
+ this.prManager.updateCommentCount();
288
+ }
289
+
290
+ } catch (error) {
291
+ console.error('Error saving file-level comment:', error);
292
+ if (window.toast) {
293
+ window.toast.showError('Failed to save file-level comment');
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Display a user file-level comment
300
+ * Note: Dismissed comments are never rendered in the diff view per design decision.
301
+ * They only appear in the AI/Review Panel. This method only receives active comments.
302
+ * @param {HTMLElement} zone - The file comments zone
303
+ * @param {Object} comment - The comment data
304
+ */
305
+ displayUserComment(zone, comment) {
306
+ const container = zone.querySelector('.file-comments-container');
307
+
308
+ const card = document.createElement('div');
309
+ // Match line-level: add adopted-comment and comment-ai-origin classes when AI-originated
310
+ const isAIOrigin = !!comment.parent_id;
311
+ card.className = `file-comment-card user-comment ${isAIOrigin ? 'adopted-comment comment-ai-origin' : 'comment-user-origin'}`;
312
+ card.dataset.commentId = comment.id;
313
+
314
+ const renderedBody = window.renderMarkdown
315
+ ? window.renderMarkdown(comment.body)
316
+ : this.escapeHtml(comment.body);
317
+
318
+ // Choose icon based on comment origin (AI-adopted vs user-originated) - matches line-level
319
+ const commentIcon = isAIOrigin
320
+ ? `<svg class="octicon octicon-comment-ai" viewBox="0 0 16 16" width="16" height="16">
321
+ <path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/>
322
+ </svg>`
323
+ : `<svg class="octicon octicon-person" viewBox="0 0 16 16" width="16" height="16">
324
+ <path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
325
+ </svg>`;
326
+
327
+ // Praise badge for "Nice Work" comments - matches line-level
328
+ const praiseBadge = comment.type === 'praise'
329
+ ? `<span class="adopted-praise-badge" title="Nice Work"><svg viewBox="0 0 16 16" width="12" height="12"><path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/></svg>Nice Work</span>`
330
+ : '';
331
+
332
+ // Title for AI-adopted comments - matches line-level
333
+ const titleHtml = comment.title
334
+ ? `<span class="adopted-title">${this.escapeHtml(comment.title)}</span>`
335
+ : '';
336
+
337
+ // Use same structure as line-level user comments
338
+ card.innerHTML = `
339
+ <div class="user-comment-header">
340
+ <span class="comment-origin-icon">
341
+ ${commentIcon}
342
+ </span>
343
+ <span class="file-comment-badge" title="Comment applies to the entire file">File comment</span>
344
+ ${praiseBadge}
345
+ ${titleHtml}
346
+ <span class="user-comment-timestamp">${this.formatTimestamp(comment.created_at)}</span>
347
+ <div class="user-comment-actions">
348
+ <button class="btn-edit-comment" title="Edit comment">
349
+ <svg class="octicon" viewBox="0 0 16 16" width="16" height="16">
350
+ <path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path>
351
+ </svg>
352
+ </button>
353
+ <button class="btn-delete-comment" title="Dismiss comment">
354
+ <svg class="octicon" viewBox="0 0 16 16" width="16" height="16">
355
+ <path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path>
356
+ </svg>
357
+ </button>
358
+ </div>
359
+ </div>
360
+ <div class="user-comment-body" data-original-markdown="${window.escapeHtmlAttribute(comment.body)}">${renderedBody}</div>
361
+ `;
362
+
363
+ // Wire up edit/delete buttons
364
+ const editBtn = card.querySelector('.btn-edit-comment');
365
+ const deleteBtn = card.querySelector('.btn-delete-comment');
366
+
367
+ editBtn.addEventListener('click', () => this.editFileComment(zone, comment));
368
+ deleteBtn.addEventListener('click', () => this.deleteFileComment(zone, comment.id));
369
+
370
+ // Insert before form if present, otherwise append
371
+ const form = container.querySelector('.file-comment-form');
372
+ if (form) {
373
+ container.insertBefore(card, form);
374
+ } else {
375
+ container.appendChild(card);
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Display an AI file-level suggestion
381
+ * @param {HTMLElement} zone - The file comments zone
382
+ * @param {Object} suggestion - The suggestion data
383
+ */
384
+ displayAISuggestion(zone, suggestion) {
385
+ const container = zone.querySelector('.file-comments-container');
386
+
387
+ // Use the same structure as line-level AI suggestions for consistency
388
+ const card = document.createElement('div');
389
+ // Include ai-type-${type} class for proper category styling (especially praise badge)
390
+ card.className = `file-comment-card ai-suggestion ai-type-${suggestion.type || 'suggestion'}`;
391
+ card.dataset.suggestionId = suggestion.id;
392
+ // Store original markdown body for adopt functionality via extractSuggestionData
393
+ // Use JSON.stringify to preserve newlines and special characters (matches line-level suggestions)
394
+ card.dataset.originalBody = JSON.stringify(suggestion.body || '');
395
+
396
+ // Store target info on the card for reliable retrieval in getFileAndLineInfo
397
+ // File-level suggestions don't have line numbers, just the file name
398
+ card.dataset.fileName = suggestion.file || '';
399
+ card.dataset.lineNumber = '';
400
+ card.dataset.side = '';
401
+ card.dataset.diffPosition = '';
402
+ card.dataset.isFileLevel = 'true';
403
+
404
+ // Check if this suggestion was adopted by looking at status or user comments with matching parent_id
405
+ // This mirrors the behavior in suggestion-manager.js for line-level suggestions
406
+ const userComments = this.prManager?.userComments || [];
407
+ const suggestionIdNum = parseInt(suggestion.id);
408
+ const wasAdopted = userComments.some(comment =>
409
+ comment.parent_id && (comment.parent_id === suggestion.id || comment.parent_id === suggestionIdNum)
410
+ );
411
+
412
+ // Determine if suggestion should be collapsed based on status or adoption
413
+ const isAdopted = wasAdopted || suggestion.status === 'adopted';
414
+ const isDismissed = suggestion.status === 'dismissed';
415
+
416
+ // Apply collapsed class if the suggestion is dismissed or was adopted
417
+ if (isAdopted || isDismissed) {
418
+ card.classList.add('collapsed');
419
+ }
420
+
421
+ // Get category label for display (same as line-level)
422
+ const categoryLabel = suggestion.type || suggestion.category || '';
423
+
424
+ const renderedBody = window.renderMarkdown
425
+ ? window.renderMarkdown(suggestion.body)
426
+ : this.escapeHtml(suggestion.body);
427
+
428
+ // Use exact same HTML structure as line-level suggestions (suggestion-manager.js)
429
+ card.innerHTML = `
430
+ <div class="ai-suggestion-header">
431
+ <div class="ai-suggestion-header-left">
432
+ ${suggestion.type === 'praise'
433
+ ? `<span class="praise-badge" title="Nice Work"><svg viewBox="0 0 16 16"><path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/></svg>Nice Work</span>`
434
+ : `<span class="ai-suggestion-badge" data-type="${suggestion.type}" title="AI Suggestion"><svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M9.6 2.279a.426.426 0 0 1 .8 0l.407 1.112a6.386 6.386 0 0 0 3.802 3.802l1.112.407a.426.426 0 0 1 0 .8l-1.112.407a6.386 6.386 0 0 0-3.802 3.802l-.407 1.112a.426.426 0 0 1-.8 0l-.407-1.112a6.386 6.386 0 0 0-3.802-3.802L4.279 8.4a.426.426 0 0 1 0-.8l1.112-.407a6.386 6.386 0 0 0 3.802-3.802L9.6 2.279Zm-4.267 8.837a.178.178 0 0 1 .334 0l.169.464a2.662 2.662 0 0 0 1.584 1.584l.464.169a.178.178 0 0 1 0 .334l-.464.169a2.662 2.662 0 0 0-1.584 1.584l-.169.464a.178.178 0 0 1-.334 0l-.169-.464a2.662 2.662 0 0 0-1.584-1.584l-.464-.169a.178.178 0 0 1 0-.334l.464-.169a2.662 2.662 0 0 0 1.584-1.584l.169-.464ZM2.8.14a.213.213 0 0 1 .4 0l.203.556a3.2 3.2 0 0 0 1.901 1.901l.556.203a.213.213 0 0 1 0 .4l-.556.203a3.2 3.2 0 0 0-1.901 1.901L3.2 5.86a.213.213 0 0 1-.4 0l-.203-.556A3.2 3.2 0 0 0 .696 3.403L.14 3.2a.213.213 0 0 1 0-.4l.556-.203A3.2 3.2 0 0 0 2.597.696L2.8.14Z"/></svg>AI Suggestion</span>`}
435
+ <span class="file-comment-badge" title="Comment applies to the entire file">File comment</span>
436
+ ${categoryLabel ? `<span class="ai-suggestion-category">${this.escapeHtml(categoryLabel)}</span>` : ''}
437
+ <span class="ai-title">${this.escapeHtml(suggestion.title || '')}</span>
438
+ </div>
439
+ </div>
440
+ <div class="ai-suggestion-collapsed-content">
441
+ ${suggestion.type === 'praise'
442
+ ? `<span class="praise-badge" title="Nice Work"><svg viewBox="0 0 16 16"><path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/></svg>Nice Work</span>`
443
+ : `<span class="ai-suggestion-badge collapsed" data-type="${suggestion.type}" title="AI Suggestion"><svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10"><path d="M9.6 2.279a.426.426 0 0 1 .8 0l.407 1.112a6.386 6.386 0 0 0 3.802 3.802l1.112.407a.426.426 0 0 1 0 .8l-1.112.407a6.386 6.386 0 0 0-3.802 3.802l-.407 1.112a.426.426 0 0 1-.8 0l-.407-1.112a6.386 6.386 0 0 0-3.802-3.802L4.279 8.4a.426.426 0 0 1 0-.8l1.112-.407a6.386 6.386 0 0 0 3.802-3.802L9.6 2.279Zm-4.267 8.837a.178.178 0 0 1 .334 0l.169.464a2.662 2.662 0 0 0 1.584 1.584l.464.169a.178.178 0 0 1 0 .334l-.464.169a2.662 2.662 0 0 0-1.584 1.584l-.169.464a.178.178 0 0 1-.334 0l-.169-.464a2.662 2.662 0 0 0-1.584-1.584l-.464-.169a.178.178 0 0 1 0-.334l.464-.169a2.662 2.662 0 0 0 1.584-1.584l.169-.464ZM2.8.14a.213.213 0 0 1 .4 0l.203.556a3.2 3.2 0 0 0 1.901 1.901l.556.203a.213.213 0 0 1 0 .4l-.556.203a3.2 3.2 0 0 0-1.901 1.901L3.2 5.86a.213.213 0 0 1-.4 0l-.203-.556A3.2 3.2 0 0 0 .696 3.403L.14 3.2a.213.213 0 0 1 0-.4l.556-.203A3.2 3.2 0 0 0 2.597.696L2.8.14Z"/></svg>AI Suggestion</span>`}
444
+ <span class="collapsed-text">${isAdopted ? 'Suggestion adopted' : 'Hidden AI suggestion'}</span>
445
+ <span class="collapsed-title">${this.escapeHtml(suggestion.title || '')}</span>
446
+ <button class="btn-restore" title="Show suggestion">
447
+ <svg class="octicon octicon-eye" viewBox="0 0 16 16" width="16" height="16">
448
+ <path fill-rule="evenodd" d="M1.679 7.932c.412-.621 1.242-1.75 2.366-2.717C5.175 4.242 6.527 3.5 8 3.5c1.473 0 2.824.742 3.955 1.715 1.124.967 1.954 2.096 2.366 2.717a.119.119 0 010 .136c-.412.621-1.242 1.75-2.366 2.717C10.825 11.758 9.473 12.5 8 12.5c-1.473 0-2.824-.742-3.955-1.715C2.92 9.818 2.09 8.69 1.679 8.068a.119.119 0 010-.136zM8 2c-1.981 0-3.67.992-4.933 2.078C1.797 5.169.88 6.423.43 7.1a1.619 1.619 0 000 1.798c.45.678 1.367 1.932 2.637 3.024C4.329 13.008 6.019 14 8 14c1.981 0 3.67-.992 4.933-2.078 1.27-1.091 2.187-2.345 2.637-3.023a1.619 1.619 0 000-1.798c-.45-.678-1.367-1.932-2.637-3.023C11.671 2.992 9.981 2 8 2zm0 8a2 2 0 100-4 2 2 0 000 4z"></path>
449
+ </svg>
450
+ <span class="btn-text">Show</span>
451
+ </button>
452
+ </div>
453
+ <div class="ai-suggestion-body">
454
+ ${renderedBody}
455
+ </div>
456
+ <div class="ai-suggestion-actions">
457
+ <button class="ai-action ai-action-adopt">
458
+ <svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>
459
+ Adopt
460
+ </button>
461
+ <button class="ai-action ai-action-edit">
462
+ <svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path></svg>
463
+ Edit
464
+ </button>
465
+ <button class="ai-action ai-action-dismiss">
466
+ <svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"></path></svg>
467
+ Dismiss
468
+ </button>
469
+ </div>
470
+ `;
471
+
472
+ // Wire up action buttons (using same class names as line-level)
473
+ const adoptBtn = card.querySelector('.ai-action-adopt');
474
+ const dismissBtn = card.querySelector('.ai-action-dismiss');
475
+ const editBtn = card.querySelector('.ai-action-edit');
476
+ const restoreBtn = card.querySelector('.btn-restore');
477
+
478
+ adoptBtn.addEventListener('click', () => this.adoptAISuggestion(zone, suggestion));
479
+ dismissBtn.addEventListener('click', () => this.dismissAISuggestion(zone, suggestion.id));
480
+ editBtn.addEventListener('click', () => this.editAndAdoptAISuggestion(zone, suggestion));
481
+ restoreBtn.addEventListener('click', async () => await this.restoreAISuggestion(zone, suggestion.id));
482
+
483
+ // Insert at the beginning (AI suggestions shown first)
484
+ const firstUserComment = container.querySelector('.file-comment-card:not(.ai-suggestion)');
485
+ if (firstUserComment) {
486
+ container.insertBefore(card, firstUserComment);
487
+ } else {
488
+ const form = container.querySelector('.file-comment-form');
489
+ if (form) {
490
+ container.insertBefore(card, form);
491
+ } else {
492
+ container.appendChild(card);
493
+ }
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Adopt an AI suggestion as a user comment
499
+ * @param {HTMLElement} zone - The file comments zone
500
+ * @param {Object} suggestion - The suggestion data
501
+ */
502
+ async adoptAISuggestion(zone, suggestion) {
503
+ try {
504
+ // Format the comment body with category prefix (matches line-level behavior)
505
+ const formattedBody = this.formatAdoptedComment(suggestion.body, suggestion.type);
506
+
507
+ // Create a file-level user comment from the suggestion, including parent_id/type/title for adopted suggestions
508
+ const { endpoint, requestBody } = this._getFileCommentEndpoint('create', {
509
+ file: suggestion.file,
510
+ body: formattedBody,
511
+ parent_id: suggestion.id,
512
+ type: suggestion.type,
513
+ title: suggestion.title
514
+ });
515
+
516
+ const createResponse = await fetch(endpoint, {
517
+ method: 'POST',
518
+ headers: { 'Content-Type': 'application/json' },
519
+ body: JSON.stringify(requestBody)
520
+ });
521
+
522
+ if (!createResponse.ok) throw new Error('Failed to create user comment');
523
+
524
+ const createResult = await createResponse.json();
525
+
526
+ // Update the AI suggestion status to adopted (mode-aware endpoint)
527
+ const statusEndpoint = this._getSuggestionStatusEndpoint(suggestion.id);
528
+ const statusResponse = await fetch(statusEndpoint, {
529
+ method: 'POST',
530
+ headers: { 'Content-Type': 'application/json' },
531
+ body: JSON.stringify({ status: 'adopted' })
532
+ });
533
+
534
+ if (!statusResponse.ok) throw new Error('Failed to update suggestion status');
535
+
536
+ // Collapse the AI suggestion card instead of removing it
537
+ const suggestionCard = zone.querySelector(`[data-suggestion-id="${suggestion.id}"]`);
538
+ if (suggestionCard) {
539
+ suggestionCard.classList.add('collapsed');
540
+ // Update collapsed text to show "Suggestion adopted"
541
+ const collapsedText = suggestionCard.querySelector('.collapsed-text');
542
+ if (collapsedText) {
543
+ collapsedText.textContent = 'Suggestion adopted';
544
+ }
545
+ }
546
+
547
+ // Display as user comment with formatted body
548
+ const commentData = {
549
+ id: createResult.commentId,
550
+ file: suggestion.file,
551
+ body: formattedBody,
552
+ source: 'user',
553
+ parent_id: suggestion.id,
554
+ type: suggestion.type,
555
+ title: suggestion.title,
556
+ is_file_level: 1,
557
+ created_at: new Date().toISOString()
558
+ };
559
+
560
+ this.displayUserComment(zone, commentData);
561
+ this.updateCommentCount(zone);
562
+
563
+ // Update parent comment count for Preview button
564
+ if (this.prManager?.updateCommentCount) {
565
+ this.prManager.updateCommentCount();
566
+ }
567
+
568
+ // Add comment to AI Panel's comment list for navigation and display
569
+ if (window.aiPanel?.addComment) {
570
+ window.aiPanel.addComment(commentData);
571
+ }
572
+
573
+ // Update finding status in AI Panel (mark suggestion as adopted)
574
+ if (window.aiPanel?.updateFindingStatus) {
575
+ window.aiPanel.updateFindingStatus(suggestion.id, 'adopted');
576
+ }
577
+
578
+ } catch (error) {
579
+ console.error('Error adopting suggestion:', error);
580
+ if (window.toast) {
581
+ window.toast.showError('Failed to adopt suggestion');
582
+ }
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Dismiss an AI suggestion
588
+ * @param {HTMLElement} zone - The file comments zone
589
+ * @param {number} suggestionId - The suggestion ID
590
+ */
591
+ async dismissAISuggestion(zone, suggestionId) {
592
+ try {
593
+ // Update the AI suggestion status to dismissed (mode-aware endpoint)
594
+ const endpoint = this._getSuggestionStatusEndpoint(suggestionId);
595
+ const response = await fetch(endpoint, {
596
+ method: 'POST',
597
+ headers: { 'Content-Type': 'application/json' },
598
+ body: JSON.stringify({ status: 'dismissed' })
599
+ });
600
+
601
+ if (!response.ok) throw new Error('Failed to dismiss suggestion');
602
+
603
+ // Collapse the card instead of removing it
604
+ const card = zone.querySelector(`[data-suggestion-id="${suggestionId}"]`);
605
+ if (card) {
606
+ card.classList.add('collapsed');
607
+ // Update collapsed text to show "Hidden AI suggestion"
608
+ const collapsedText = card.querySelector('.collapsed-text');
609
+ if (collapsedText) {
610
+ collapsedText.textContent = 'Hidden AI suggestion';
611
+ }
612
+ }
613
+
614
+ this.updateCommentCount(zone);
615
+
616
+ // Update finding status in AI Panel (mark suggestion as dismissed)
617
+ if (window.aiPanel?.updateFindingStatus) {
618
+ window.aiPanel.updateFindingStatus(suggestionId, 'dismissed');
619
+ }
620
+
621
+ } catch (error) {
622
+ console.error('Error dismissing suggestion:', error);
623
+ if (window.toast) {
624
+ window.toast.showError('Failed to dismiss suggestion');
625
+ }
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Restore (show) a collapsed AI suggestion
631
+ * @param {HTMLElement} zone - The file comments zone
632
+ * @param {number} suggestionId - The suggestion ID
633
+ */
634
+ async restoreAISuggestion(zone, suggestionId) {
635
+ try {
636
+ // Use shared helper for mode-aware endpoint
637
+ const endpoint = this._getSuggestionStatusEndpoint(suggestionId);
638
+
639
+ // Call API to update suggestion status to active
640
+ const response = await fetch(endpoint, {
641
+ method: 'POST',
642
+ headers: { 'Content-Type': 'application/json' },
643
+ body: JSON.stringify({ status: 'active' })
644
+ });
645
+
646
+ if (!response.ok) throw new Error('Failed to restore suggestion');
647
+
648
+ // Update the UI - remove collapsed state
649
+ const card = zone.querySelector(`[data-suggestion-id="${suggestionId}"]`);
650
+ if (card) {
651
+ card.classList.remove('collapsed');
652
+ }
653
+
654
+ // Update finding status in AI Panel (mark suggestion as active)
655
+ if (window.aiPanel?.updateFindingStatus) {
656
+ window.aiPanel.updateFindingStatus(suggestionId, 'active');
657
+ }
658
+
659
+ // Update comment count (for consistency with dismissAISuggestion)
660
+ this.updateCommentCount(zone);
661
+
662
+ } catch (error) {
663
+ console.error('Error restoring suggestion:', error);
664
+ if (window.toast) {
665
+ window.toast.showError('Failed to restore suggestion');
666
+ }
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Edit and adopt an AI suggestion
672
+ * @param {HTMLElement} zone - The file comments zone
673
+ * @param {Object} suggestion - The suggestion data
674
+ */
675
+ editAndAdoptAISuggestion(zone, suggestion) {
676
+ const container = zone.querySelector('.file-comments-container');
677
+
678
+ // Close any existing form
679
+ const existingForm = container.querySelector('.file-comment-form');
680
+ if (existingForm) {
681
+ existingForm.remove();
682
+ }
683
+
684
+ // Create form with pre-filled content
685
+ const form = document.createElement('div');
686
+ form.className = 'file-comment-form focused';
687
+ form.dataset.suggestionId = suggestion.id;
688
+ form.innerHTML = `
689
+ <div class="file-comment-form-header">
690
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
691
+ <path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5z"/>
692
+ </svg>
693
+ <label>Edit AI suggestion</label>
694
+ </div>
695
+ <textarea
696
+ class="file-comment-textarea"
697
+ placeholder="Edit the suggestion..."
698
+ data-file="${window.escapeHtmlAttribute(suggestion.file)}"
699
+ >${this.escapeHtml(suggestion.body)}</textarea>
700
+ <div class="file-comment-form-footer">
701
+ <button class="file-comment-form-btn submit submit-btn">Adopt</button>
702
+ <button class="file-comment-form-btn cancel cancel-btn">Cancel</button>
703
+ </div>
704
+ `;
705
+
706
+ // Insert form after the suggestion card
707
+ const suggestionCard = zone.querySelector(`[data-suggestion-id="${suggestion.id}"]`);
708
+ if (suggestionCard) {
709
+ suggestionCard.after(form);
710
+ } else {
711
+ container.appendChild(form);
712
+ }
713
+
714
+ const textarea = form.querySelector('.file-comment-textarea');
715
+ const submitBtn = form.querySelector('.submit-btn');
716
+ const cancelBtn = form.querySelector('.cancel-btn');
717
+
718
+ textarea.focus();
719
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
720
+
721
+ textarea.addEventListener('focus', () => form.classList.add('focused'));
722
+ textarea.addEventListener('blur', () => form.classList.remove('focused'));
723
+
724
+ textarea.addEventListener('input', () => {
725
+ submitBtn.disabled = !textarea.value.trim();
726
+ });
727
+
728
+ textarea.addEventListener('keydown', (e) => {
729
+ if (e.key === 'Escape') {
730
+ form.remove();
731
+ } else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && textarea.value.trim()) {
732
+ this.adoptWithEdit(zone, suggestion, textarea.value.trim());
733
+ form.remove();
734
+ }
735
+ });
736
+
737
+ cancelBtn.addEventListener('click', () => form.remove());
738
+
739
+ submitBtn.addEventListener('click', () => {
740
+ if (textarea.value.trim()) {
741
+ this.adoptWithEdit(zone, suggestion, textarea.value.trim());
742
+ form.remove();
743
+ }
744
+ });
745
+ }
746
+
747
+ /**
748
+ * Adopt an AI suggestion with edited body
749
+ * @param {HTMLElement} zone - The file comments zone
750
+ * @param {Object} suggestion - The original suggestion
751
+ * @param {string} editedBody - The edited comment body
752
+ */
753
+ async adoptWithEdit(zone, suggestion, editedBody) {
754
+ try {
755
+ // Format the edited body with category prefix (matches line-level behavior)
756
+ const formattedBody = this.formatAdoptedComment(editedBody, suggestion.type);
757
+
758
+ // Create a file-level user comment with the edited body, including parent_id/type/title for adopted suggestions
759
+ const { endpoint, requestBody } = this._getFileCommentEndpoint('create', {
760
+ file: suggestion.file,
761
+ body: formattedBody,
762
+ parent_id: suggestion.id,
763
+ type: suggestion.type,
764
+ title: suggestion.title
765
+ });
766
+
767
+ const createResponse = await fetch(endpoint, {
768
+ method: 'POST',
769
+ headers: { 'Content-Type': 'application/json' },
770
+ body: JSON.stringify(requestBody)
771
+ });
772
+
773
+ if (!createResponse.ok) throw new Error('Failed to create user comment');
774
+
775
+ const createResult = await createResponse.json();
776
+
777
+ // Update the AI suggestion status to adopted (mode-aware endpoint)
778
+ const statusEndpoint = this._getSuggestionStatusEndpoint(suggestion.id);
779
+ const statusResponse = await fetch(statusEndpoint, {
780
+ method: 'POST',
781
+ headers: { 'Content-Type': 'application/json' },
782
+ body: JSON.stringify({ status: 'adopted' })
783
+ });
784
+
785
+ if (!statusResponse.ok) throw new Error('Failed to update suggestion status');
786
+
787
+ // Collapse the AI suggestion card instead of removing it
788
+ const suggestionCard = zone.querySelector(`[data-suggestion-id="${suggestion.id}"]`);
789
+ if (suggestionCard) {
790
+ suggestionCard.classList.add('collapsed');
791
+ // Update collapsed text to show "Suggestion adopted"
792
+ const collapsedText = suggestionCard.querySelector('.collapsed-text');
793
+ if (collapsedText) {
794
+ collapsedText.textContent = 'Suggestion adopted';
795
+ }
796
+ }
797
+
798
+ // Display as user comment with formatted body
799
+ const commentData = {
800
+ id: createResult.commentId,
801
+ file: suggestion.file,
802
+ body: formattedBody,
803
+ source: 'user',
804
+ parent_id: suggestion.id,
805
+ type: suggestion.type,
806
+ title: suggestion.title,
807
+ is_file_level: 1,
808
+ created_at: new Date().toISOString()
809
+ };
810
+
811
+ this.displayUserComment(zone, commentData);
812
+ this.updateCommentCount(zone);
813
+
814
+ // Update parent comment count for Preview button
815
+ if (this.prManager?.updateCommentCount) {
816
+ this.prManager.updateCommentCount();
817
+ }
818
+
819
+ // Add comment to AI Panel's comment list for navigation and display
820
+ if (window.aiPanel?.addComment) {
821
+ window.aiPanel.addComment(commentData);
822
+ }
823
+
824
+ // Update finding status in AI Panel (mark suggestion as adopted)
825
+ if (window.aiPanel?.updateFindingStatus) {
826
+ window.aiPanel.updateFindingStatus(suggestion.id, 'adopted');
827
+ }
828
+
829
+ } catch (error) {
830
+ console.error('Error adopting suggestion with edit:', error);
831
+ if (window.toast) {
832
+ window.toast.showError('Failed to adopt suggestion');
833
+ }
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Edit a user file-level comment
839
+ * @param {HTMLElement} zone - The file comments zone
840
+ * @param {Object} comment - The comment data
841
+ */
842
+ editFileComment(zone, comment) {
843
+ const card = zone.querySelector(`[data-comment-id="${comment.id}"]`);
844
+ if (!card) return;
845
+
846
+ const bodyEl = card.querySelector('.user-comment-body');
847
+ const originalMarkdown = bodyEl.dataset.originalMarkdown || comment.body;
848
+
849
+ // Replace body with edit form (matching line-level comment edit form styling)
850
+ bodyEl.innerHTML = `
851
+ <textarea class="file-comment-textarea" style="min-height: 80px;">${this.escapeHtml(originalMarkdown)}</textarea>
852
+ <div class="comment-edit-actions">
853
+ <button class="btn btn-sm btn-primary save-edit-btn">Save</button>
854
+ <button class="btn btn-sm btn-secondary cancel-edit-btn">Cancel</button>
855
+ </div>
856
+ `;
857
+
858
+ const textarea = bodyEl.querySelector('.file-comment-textarea');
859
+ const saveBtn = bodyEl.querySelector('.save-edit-btn');
860
+ const cancelBtn = bodyEl.querySelector('.cancel-edit-btn');
861
+
862
+ textarea.focus();
863
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
864
+
865
+ const restoreView = () => {
866
+ const renderedBody = window.renderMarkdown
867
+ ? window.renderMarkdown(originalMarkdown)
868
+ : this.escapeHtml(originalMarkdown);
869
+ bodyEl.innerHTML = renderedBody;
870
+ bodyEl.dataset.originalMarkdown = originalMarkdown;
871
+ };
872
+
873
+ cancelBtn.addEventListener('click', restoreView);
874
+
875
+ textarea.addEventListener('keydown', (e) => {
876
+ if (e.key === 'Escape') {
877
+ restoreView();
878
+ } else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && textarea.value.trim()) {
879
+ this.saveEditedComment(zone, comment.id, textarea.value.trim(), bodyEl);
880
+ }
881
+ });
882
+
883
+ saveBtn.addEventListener('click', () => {
884
+ if (textarea.value.trim()) {
885
+ this.saveEditedComment(zone, comment.id, textarea.value.trim(), bodyEl);
886
+ }
887
+ });
888
+ }
889
+
890
+ /**
891
+ * Save an edited comment
892
+ * @param {HTMLElement} zone - The file comments zone
893
+ * @param {number} commentId - The comment ID
894
+ * @param {string} newBody - The new comment body
895
+ * @param {HTMLElement} bodyEl - The body element to update
896
+ */
897
+ async saveEditedComment(zone, commentId, newBody, bodyEl) {
898
+ try {
899
+ const { endpoint, requestBody } = this._getFileCommentEndpoint('update', {
900
+ commentId: commentId,
901
+ body: newBody
902
+ });
903
+
904
+ const response = await fetch(endpoint, {
905
+ method: 'PUT',
906
+ headers: { 'Content-Type': 'application/json' },
907
+ body: JSON.stringify(requestBody)
908
+ });
909
+
910
+ if (!response.ok) throw new Error('Failed to update comment');
911
+
912
+ // Update the display
913
+ const renderedBody = window.renderMarkdown
914
+ ? window.renderMarkdown(newBody)
915
+ : this.escapeHtml(newBody);
916
+ bodyEl.innerHTML = renderedBody;
917
+ bodyEl.dataset.originalMarkdown = newBody;
918
+
919
+ } catch (error) {
920
+ console.error('Error updating comment:', error);
921
+ if (window.toast) {
922
+ window.toast.showError('Failed to update comment');
923
+ }
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Delete a user file-level comment
929
+ * @param {HTMLElement} zone - The file comments zone
930
+ * @param {number} commentId - The comment ID
931
+ */
932
+ async deleteFileComment(zone, commentId) {
933
+ try {
934
+ const { endpoint } = this._getFileCommentEndpoint('delete', {
935
+ commentId: commentId
936
+ });
937
+
938
+ const response = await fetch(endpoint, {
939
+ method: 'DELETE'
940
+ });
941
+
942
+ if (!response.ok) throw new Error('Failed to delete comment');
943
+
944
+ const apiResult = await response.json();
945
+
946
+ // Remove the card
947
+ const card = zone.querySelector(`[data-comment-id="${commentId}"]`);
948
+ if (card) {
949
+ card.remove();
950
+ }
951
+
952
+ this.updateCommentCount(zone);
953
+
954
+ // Update parent comment count
955
+ if (this.prManager?.updateCommentCount) {
956
+ this.prManager.updateCommentCount();
957
+ }
958
+
959
+ // Notify AI Panel about the deleted comment
960
+ if (window.aiPanel?.removeComment) {
961
+ window.aiPanel.removeComment(commentId);
962
+ }
963
+
964
+ // If a parent suggestion existed, the suggestion card is still collapsed/dismissed in the diff view.
965
+ // Update AIPanel to show the suggestion as 'dismissed' (matching its visual state).
966
+ // User can click "Show" to restore it to active state if they want to re-adopt.
967
+ if (apiResult.dismissedSuggestionId && window.aiPanel?.updateFindingStatus) {
968
+ window.aiPanel.updateFindingStatus(apiResult.dismissedSuggestionId, 'dismissed');
969
+ }
970
+
971
+ } catch (error) {
972
+ console.error('Error deleting comment:', error);
973
+ if (window.toast) {
974
+ window.toast.showError('Failed to delete comment');
975
+ }
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Get the appropriate API endpoint for updating AI suggestion status
981
+ * Handles both local and PR modes.
982
+ * @private
983
+ * @param {number|string} suggestionId - The suggestion ID
984
+ * @returns {string} The API endpoint URL
985
+ */
986
+ _getSuggestionStatusEndpoint(suggestionId) {
987
+ const reviewId = this.prManager?.currentPR?.id;
988
+ const reviewType = this.prManager?.currentPR?.reviewType;
989
+ const isLocal = reviewType === 'local';
990
+
991
+ return isLocal
992
+ ? `/api/local/${reviewId}/ai-suggestion/${suggestionId}/status`
993
+ : `/api/ai-suggestion/${suggestionId}/status`;
994
+ }
995
+
996
+ /**
997
+ * Update the zone state based on comment count
998
+ * @param {HTMLElement} zone - The file comments zone
999
+ */
1000
+ updateCommentCount(zone) {
1001
+ const container = zone.querySelector('.file-comments-container');
1002
+
1003
+ const userComments = container.querySelectorAll('.file-comment-card:not(.ai-suggestion)').length;
1004
+ const aiSuggestions = container.querySelectorAll('.file-comment-card.ai-suggestion').length;
1005
+ const total = userComments + aiSuggestions;
1006
+
1007
+ // Update header button icon state (outline vs filled)
1008
+ this.updateHeaderButtonState(zone, total);
1009
+ }
1010
+
1011
+ /**
1012
+ * Update the header button icon state based on comment count.
1013
+ *
1014
+ * Note: The `zone.headerButton` property is injected externally by pr.js
1015
+ * during file header rendering. This coupling allows the file-comment-manager
1016
+ * to update the header's comment icon state (outline vs filled) without
1017
+ * needing direct access to the header DOM structure.
1018
+ *
1019
+ * @param {HTMLElement} zone - The file comments zone element
1020
+ * @param {number} count - Total comment count (user + AI suggestions)
1021
+ */
1022
+ updateHeaderButtonState(zone, count) {
1023
+ const headerBtn = zone.headerButton;
1024
+ if (!headerBtn) return;
1025
+
1026
+ const outlineIcon = headerBtn.querySelector('.comment-icon-outline');
1027
+ const filledIcon = headerBtn.querySelector('.comment-icon-filled');
1028
+
1029
+ if (count > 0) {
1030
+ // Has comments - show filled icon
1031
+ if (outlineIcon) outlineIcon.style.display = 'none';
1032
+ if (filledIcon) filledIcon.style.display = '';
1033
+ headerBtn.classList.add('has-comments');
1034
+ headerBtn.title = `${count} file comment${count > 1 ? 's' : ''} - click to add more`;
1035
+ } else {
1036
+ // No comments - show outline icon
1037
+ if (outlineIcon) outlineIcon.style.display = '';
1038
+ if (filledIcon) filledIcon.style.display = 'none';
1039
+ headerBtn.classList.remove('has-comments');
1040
+ headerBtn.title = 'Add file comment';
1041
+ }
1042
+ }
1043
+
1044
+ /**
1045
+ * Load and display file-level comments for all files
1046
+ * @param {Array} comments - Array of file-level comments
1047
+ * @param {Array} suggestions - Array of file-level AI suggestions
1048
+ */
1049
+ loadFileComments(comments, suggestions) {
1050
+ // Group by file
1051
+ const commentsByFile = new Map();
1052
+ const suggestionsByFile = new Map();
1053
+
1054
+ if (comments) {
1055
+ for (const comment of comments) {
1056
+ if (comment.is_file_level === 1) {
1057
+ if (!commentsByFile.has(comment.file)) {
1058
+ commentsByFile.set(comment.file, []);
1059
+ }
1060
+ commentsByFile.get(comment.file).push(comment);
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ if (suggestions) {
1066
+ for (const suggestion of suggestions) {
1067
+ if (suggestion.is_file_level === 1) {
1068
+ if (!suggestionsByFile.has(suggestion.file)) {
1069
+ suggestionsByFile.set(suggestion.file, []);
1070
+ }
1071
+ suggestionsByFile.get(suggestion.file).push(suggestion);
1072
+ }
1073
+ }
1074
+ }
1075
+
1076
+ // Find all file comment zones and populate them
1077
+ const zones = document.querySelectorAll('.file-comments-zone');
1078
+ for (const zone of zones) {
1079
+ const fileName = zone.dataset.fileName;
1080
+ const container = zone.querySelector('.file-comments-container');
1081
+
1082
+ // Selectively clear existing cards based on what we're about to reload
1083
+ // This prevents user comments from being cleared when only reloading AI suggestions
1084
+ if (container) {
1085
+ // Only clear AI suggestions if we have suggestions to display
1086
+ // (prevents stale suggestions from persisting when reloading or changing levels)
1087
+ if (suggestions && suggestions.length > 0) {
1088
+ const existingAISuggestions = container.querySelectorAll('.file-comment-card.ai-suggestion');
1089
+ for (const card of existingAISuggestions) {
1090
+ card.remove();
1091
+ }
1092
+ }
1093
+
1094
+ // Only clear user comments if we have comments to display
1095
+ if (comments && comments.length > 0) {
1096
+ const existingUserComments = container.querySelectorAll('.file-comment-card.user-comment');
1097
+ for (const card of existingUserComments) {
1098
+ card.remove();
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ const fileComments = commentsByFile.get(fileName) || [];
1104
+ const fileSuggestions = suggestionsByFile.get(fileName) || [];
1105
+
1106
+ // Display AI suggestions first
1107
+ for (const suggestion of fileSuggestions) {
1108
+ this.displayAISuggestion(zone, suggestion);
1109
+ }
1110
+
1111
+ // Then user comments
1112
+ for (const comment of fileComments) {
1113
+ this.displayUserComment(zone, comment);
1114
+ }
1115
+
1116
+ // Update count
1117
+ this.updateCommentCount(zone);
1118
+ }
1119
+ }
1120
+
1121
+ /**
1122
+ * Find the file comments zone for a given file
1123
+ * @param {string} fileName - The file path
1124
+ * @returns {HTMLElement|null} The zone element or null
1125
+ */
1126
+ findZoneForFile(fileName) {
1127
+ return document.querySelector(`.file-comments-zone[data-file-name="${fileName}"]`);
1128
+ }
1129
+
1130
+ /**
1131
+ * Escape HTML characters
1132
+ * @param {string} text - Text to escape
1133
+ * @returns {string} Escaped text
1134
+ */
1135
+ escapeHtml(text) {
1136
+ if (!text) return '';
1137
+ const div = document.createElement('div');
1138
+ div.textContent = text;
1139
+ return div.innerHTML;
1140
+ }
1141
+
1142
+ /**
1143
+ * Format timestamp for display
1144
+ * @param {string} timestamp - ISO timestamp
1145
+ * @returns {string} Formatted timestamp
1146
+ */
1147
+ formatTimestamp(timestamp) {
1148
+ if (!timestamp) return 'Just now';
1149
+ const date = new Date(timestamp);
1150
+ const now = new Date();
1151
+ const diffMs = now - date;
1152
+ const diffMins = Math.floor(diffMs / 60000);
1153
+ const diffHours = Math.floor(diffMs / 3600000);
1154
+ const diffDays = Math.floor(diffMs / 86400000);
1155
+
1156
+ if (diffMins < 1) return 'Just now';
1157
+ if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
1158
+ if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
1159
+ if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
1160
+ return date.toLocaleDateString();
1161
+ }
1162
+
1163
+ /**
1164
+ * Check if a suggestion block already exists in the textarea
1165
+ * @param {string} text - The textarea content
1166
+ * @returns {boolean} True if a suggestion block exists
1167
+ */
1168
+ hasSuggestionBlock(text) {
1169
+ // Match both ``` and ```` suggestion blocks, allowing leading whitespace
1170
+ return /^\s*(`{3,})suggestion\s*$/m.test(text);
1171
+ }
1172
+
1173
+ /**
1174
+ * Update the suggestion button state based on textarea content
1175
+ * Disables the button if a suggestion block already exists
1176
+ * @param {HTMLTextAreaElement} textarea - The textarea to check
1177
+ * @param {HTMLButtonElement} button - The suggestion button
1178
+ */
1179
+ updateSuggestionButtonState(textarea, button) {
1180
+ if (!button) return;
1181
+ const hasSuggestion = this.hasSuggestionBlock(textarea.value);
1182
+ button.disabled = hasSuggestion;
1183
+ button.title = hasSuggestion ? 'Only one suggestion per comment' : 'Insert a suggestion';
1184
+ }
1185
+
1186
+ /**
1187
+ * Insert a suggestion block into the textarea at cursor position
1188
+ * For file-level comments, inserts an empty suggestion block
1189
+ * @param {HTMLTextAreaElement} textarea - The textarea to insert into
1190
+ * @param {HTMLButtonElement} [button] - Optional suggestion button to disable after insert
1191
+ */
1192
+ insertSuggestionBlock(textarea, button) {
1193
+ // Check if suggestion already exists
1194
+ if (this.hasSuggestionBlock(textarea.value)) {
1195
+ return;
1196
+ }
1197
+
1198
+ // For file-level comments, insert empty suggestion block
1199
+ const backticks = '```';
1200
+ const suggestionBlock = `${backticks}suggestion\n\n${backticks}`;
1201
+
1202
+ // Get current cursor position
1203
+ const start = textarea.selectionStart;
1204
+ const end = textarea.selectionEnd;
1205
+ const text = textarea.value;
1206
+
1207
+ // Insert at cursor position (or replace selection)
1208
+ const before = text.substring(0, start);
1209
+ const after = text.substring(end);
1210
+
1211
+ // Add newlines if needed for clean formatting
1212
+ const needsNewlineBefore = before.length > 0 && !before.endsWith('\n');
1213
+ const needsNewlineAfter = after.length > 0 && !after.startsWith('\n');
1214
+
1215
+ const prefix = needsNewlineBefore ? '\n' : '';
1216
+ const suffix = needsNewlineAfter ? '\n' : '';
1217
+
1218
+ textarea.value = before + prefix + suggestionBlock + suffix + after;
1219
+
1220
+ // Position cursor inside the suggestion block
1221
+ const newCursorPos = start + prefix.length + backticks.length + 'suggestion\n'.length;
1222
+ textarea.setSelectionRange(newCursorPos, newCursorPos);
1223
+ textarea.focus();
1224
+
1225
+ // Trigger input event for auto-resize and state updates
1226
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
1227
+
1228
+ // Disable the suggestion button
1229
+ if (button) {
1230
+ button.disabled = true;
1231
+ button.title = 'Only one suggestion per comment';
1232
+ }
1233
+ }
1234
+ }
1235
+
1236
+ // Make FileCommentManager available globally
1237
+ window.FileCommentManager = FileCommentManager;
1238
+
1239
+ // Export for CommonJS testing environments
1240
+ if (typeof module !== 'undefined' && module.exports) {
1241
+ module.exports = { FileCommentManager };
1242
+ }