@in-the-loop-labs/pair-review 1.6.2 → 2.0.1

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 (63) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1962 -114
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2955 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +103 -20
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +1009 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +45 -11
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +272 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +274 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +424 -58
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-annotator.js +75 -1
  61. package/src/utils/diff-file-list.js +57 -0
  62. package/src/routes/analysis.js +0 -1600
  63. package/src/routes/comments.js +0 -534
package/public/js/pr.js CHANGED
@@ -13,19 +13,6 @@ const STALE_TIMEOUT = 2000;
13
13
 
14
14
  class PRManager {
15
15
  // Forward static constants from modules for backward compatibility
16
- static get CATEGORY_EMOJI_MAP() {
17
- return window.SuggestionManager?.CATEGORY_EMOJI_MAP || {
18
- 'bug': '\u{1F41B}',
19
- 'performance': '\u{26A1}',
20
- 'design': '\u{1F4D0}',
21
- 'code-style': '\u{1F9F9}',
22
- 'improvement': '\u{1F4A1}',
23
- 'praise': '\u{2B50}',
24
- 'security': '\u{1F512}',
25
- 'suggestion': '\u{1F4AC}'
26
- };
27
- }
28
-
29
16
  static get FOLD_UP_ICON() {
30
17
  return window.HunkParser?.FOLD_UP_ICON || '';
31
18
  }
@@ -131,14 +118,23 @@ class PRManager {
131
118
  this.collapsedFiles = new Set();
132
119
  // File viewed state - tracks which files are marked as viewed
133
120
  this.viewedFiles = new Set();
121
+ // Context files - pinned non-diff file ranges
122
+ this.contextFiles = [];
134
123
  // Canonical file order - sorted file paths for consistent ordering across components
135
124
  this.canonicalFileOrder = new Map();
125
+ // Raw per-file patch text for chat context enrichment
126
+ this.filePatches = new Map();
136
127
  // Analysis history manager - for switching between analysis runs
137
128
  this.analysisHistoryManager = null;
138
129
  // Currently selected analysis run ID (null = latest)
139
130
  this.selectedRunId = null;
140
131
  // Keyboard shortcuts manager
141
132
  this.keyboardShortcuts = null;
133
+ // Unique client ID for self-echo suppression on SSE review events.
134
+ // Sent as X-Client-Id header on mutation requests; the server echoes
135
+ // it back in the SSE broadcast so this tab can skip its own events.
136
+ this._clientId = Math.random().toString(36).slice(2) + Date.now().toString(36);
137
+ this._installFetchInterceptor();
142
138
 
143
139
  // Initialize modules
144
140
  this.lineTracker = new window.LineTracker();
@@ -184,6 +180,48 @@ class PRManager {
184
180
  }
185
181
  }
186
182
 
183
+ /**
184
+ * Install a global fetch interceptor that adds X-Client-Id to all
185
+ * mutation requests (POST/PUT/DELETE) targeting the review API.
186
+ * This is the SINGLE SOURCE of X-Client-Id injection — no individual
187
+ * fetch call site should manually set this header.
188
+ * This ensures that even direct fetch() calls (e.g. from page.evaluate
189
+ * in tests, or any code that bypasses PRManager methods) carry the
190
+ * client ID so the server can tag the SSE broadcast for self-echo
191
+ * suppression.
192
+ */
193
+ _installFetchInterceptor() {
194
+ if (window._prFetchIntercepted) return;
195
+ window._prFetchIntercepted = true;
196
+
197
+ const originalFetch = window.fetch;
198
+ const prManager = this;
199
+
200
+ window.fetch = function(input, init) {
201
+ const url = typeof input === 'string' ? input : input?.url || '';
202
+ const method = (init?.method || 'GET').toUpperCase();
203
+
204
+ // Only intercept mutations to the reviews API
205
+ if ((method === 'POST' || method === 'PUT' || method === 'DELETE') &&
206
+ url.includes('/api/reviews/') && prManager._clientId) {
207
+ init = init || {};
208
+ // Merge X-Client-Id into existing headers
209
+ if (init.headers instanceof Headers) {
210
+ if (!init.headers.has('X-Client-Id')) {
211
+ init.headers.set('X-Client-Id', prManager._clientId);
212
+ }
213
+ } else if (typeof init.headers === 'object' && init.headers !== null) {
214
+ if (!init.headers['X-Client-Id']) {
215
+ init.headers['X-Client-Id'] = prManager._clientId;
216
+ }
217
+ } else {
218
+ init.headers = { 'X-Client-Id': prManager._clientId };
219
+ }
220
+ }
221
+ return originalFetch.call(this, input, init);
222
+ };
223
+ }
224
+
187
225
  /**
188
226
  * Set up event handlers
189
227
  */
@@ -408,8 +446,8 @@ class PRManager {
408
446
  // Check if AI analysis is currently running
409
447
  await this.checkRunningAnalysis();
410
448
 
411
- // Listen for externally-imported analysis results via SSE
412
- this.startExternalResultsListener();
449
+ // Listen for review mutation events via multiplexed SSE
450
+ this._initReviewEventListeners();
413
451
 
414
452
  } catch (error) {
415
453
  console.error('Error loading PR:', error);
@@ -420,39 +458,112 @@ class PRManager {
420
458
  }
421
459
 
422
460
  /**
423
- * Listen for externally-imported analysis results via SSE.
424
- * When POST /api/analysis-results stores new suggestions for this review,
425
- * it broadcasts on `review-${reviewId}`. This listener picks that up
426
- * and refreshes suggestions automatically.
461
+ * Listen for review-scoped CustomEvents dispatched by ChatPanel's
462
+ * multiplexed SSE connection. Replaces the old per-review EventSource.
427
463
  */
428
- startExternalResultsListener() {
429
- if (this._externalResultsSource) return;
430
- const reviewId = this.currentPR?.id;
431
- if (!reviewId) return;
464
+ _initReviewEventListeners() {
465
+ if (this._reviewEventsBound) return;
466
+ this._reviewEventsBound = true;
432
467
 
433
- this._externalResultsSource = new EventSource(
434
- `/api/pr/review-${reviewId}/ai-suggestions/status`
435
- );
468
+ // Eagerly connect chat SSE so review events flow even before chat opens
469
+ window.chatPanel?._ensureGlobalSSE();
436
470
 
437
- this._externalResultsSource.onmessage = (event) => {
438
- try {
439
- const data = JSON.parse(event.data);
440
- if (data.type === 'progress' && data.status === 'completed' && data.source === 'external') {
441
- console.log('External analysis results detected, refreshing suggestions');
442
- if (this.analysisHistoryManager) {
443
- this.analysisHistoryManager.refresh({ switchToNew: true })
444
- .then(() => this.loadAISuggestions());
445
- } else {
446
- this.loadAISuggestions();
447
- }
448
- }
449
- } catch (e) { /* ignore parse errors */ }
471
+ // Late-bind reviewId to ChatPanel if it was auto-opened by PanelGroup
472
+ // before prManager was ready (DOMContentLoaded race condition)
473
+ if (this.currentPR?.id) {
474
+ window.chatPanel?._lateBindReview(this.currentPR.id).catch(err => console.warn('[ChatPanel] Late-bind failed:', err));
475
+ }
476
+
477
+ // Dirty flags for stale-tab recovery
478
+ this._dirtyComments = false;
479
+ this._dirtySuggestions = false;
480
+ this._dirtyAnalysis = false;
481
+ this._dirtyAnalysisStarted = false;
482
+ this._dirtyContextFiles = false;
483
+
484
+ // Simple debounce helper
485
+ const timers = {};
486
+ const debounced = (key, fn, ms = 300) => {
487
+ clearTimeout(timers[key]);
488
+ timers[key] = setTimeout(fn, ms);
450
489
  };
451
490
 
452
- window.addEventListener('beforeunload', () => {
453
- if (this._externalResultsSource) {
454
- this._externalResultsSource.close();
455
- this._externalResultsSource = null;
491
+ const reviewId = () => this.currentPR?.id;
492
+
493
+ document.addEventListener('review:comments_changed', (e) => {
494
+ if (e.detail?.reviewId !== reviewId()) return;
495
+ // Suppress self-echo: if this tab originated the mutation, skip reload
496
+ if (e.detail?.sourceClientId === this._clientId) return;
497
+ if (document.hidden) { this._dirtyComments = true; return; }
498
+ debounced('comments', () => this.loadUserComments());
499
+ });
500
+
501
+ document.addEventListener('review:suggestions_changed', (e) => {
502
+ if (e.detail?.reviewId !== reviewId()) return;
503
+ // Suppress self-echo for suggestion mutations too
504
+ if (e.detail?.sourceClientId === this._clientId) return;
505
+ if (document.hidden) { this._dirtySuggestions = true; return; }
506
+ debounced('suggestions', () => this.loadAISuggestions());
507
+ });
508
+
509
+ document.addEventListener('review:analysis_started', (e) => {
510
+ if (e.detail?.reviewId !== reviewId()) return;
511
+ if (document.hidden) { this._dirtyAnalysisStarted = true; return; }
512
+ debounced('analysisStarted', () => this.checkRunningAnalysis());
513
+ });
514
+
515
+ document.addEventListener('review:analysis_completed', (e) => {
516
+ if (e.detail?.reviewId !== reviewId()) return;
517
+ if (document.hidden) { this._dirtyAnalysis = true; return; }
518
+ debounced('analysis', () => {
519
+ if (this.analysisHistoryManager) {
520
+ this.analysisHistoryManager.refresh({ switchToNew: true })
521
+ .then(() => this.loadAISuggestions());
522
+ } else {
523
+ this.loadAISuggestions();
524
+ }
525
+ });
526
+ });
527
+
528
+ document.addEventListener('review:context_files_changed', (e) => {
529
+ if (e.detail?.reviewId !== reviewId()) return;
530
+ if (e.detail?.sourceClientId === this._clientId) return;
531
+ if (document.hidden) { this._dirtyContextFiles = true; return; }
532
+ debounced('contextFiles', () => this.loadContextFiles());
533
+ });
534
+
535
+ document.addEventListener('review:expand_hunk', async (e) => {
536
+ if (e.detail?.reviewId !== reviewId()) return;
537
+ const { file, line_start, line_end, side } = e.detail;
538
+ await this.ensureLinesVisible([{ file, line_start, line_end, side: side || 'right' }]);
539
+ });
540
+
541
+ document.addEventListener('visibilitychange', () => {
542
+ if (document.hidden) return;
543
+ if (this._dirtyComments) { this._dirtyComments = false; this.loadUserComments(); }
544
+ if (this._dirtyAnalysisStarted) {
545
+ this._dirtyAnalysisStarted = false;
546
+ // Skip if analysis already completed while hidden — the completed handler below will refresh everything
547
+ if (!this._dirtyAnalysis) {
548
+ this.checkRunningAnalysis();
549
+ }
550
+ }
551
+ if (this._dirtyAnalysis) {
552
+ this._dirtyAnalysis = false;
553
+ this._dirtySuggestions = false; // analysis refresh includes suggestion reload
554
+ if (this.analysisHistoryManager) {
555
+ this.analysisHistoryManager.refresh({ switchToNew: true })
556
+ .then(() => this.loadAISuggestions());
557
+ } else {
558
+ this.loadAISuggestions();
559
+ }
560
+ } else if (this._dirtySuggestions) {
561
+ this._dirtySuggestions = false;
562
+ this.loadAISuggestions();
563
+ }
564
+ if (this._dirtyContextFiles) {
565
+ this._dirtyContextFiles = false;
566
+ this.loadContextFiles();
456
567
  }
457
568
  });
458
569
  }
@@ -485,6 +596,7 @@ class PRManager {
485
596
 
486
597
  // Parse the unified diff to extract per-file patches
487
598
  const filePatchMap = this.parseUnifiedDiff(fullDiff);
599
+ this.filePatches = filePatchMap;
488
600
 
489
601
  // Merge patch data into file objects
490
602
  const filesWithPatches = files.map(file => ({
@@ -779,6 +891,10 @@ class PRManager {
779
891
  } else {
780
892
  diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
781
893
  }
894
+
895
+ // Load context files after diff is rendered
896
+ this.contextFiles = [];
897
+ this.loadContextFiles();
782
898
  }
783
899
 
784
900
  /**
@@ -855,6 +971,26 @@ class PRManager {
855
971
 
856
972
  // Store reference for updating icon state later
857
973
  fileCommentsZone.headerButton = fileCommentBtn;
974
+
975
+ // Add file chat button to header
976
+ const fileChatBtn = document.createElement('button');
977
+ fileChatBtn.className = 'file-header-chat-btn';
978
+ fileChatBtn.title = 'Chat about file';
979
+ fileChatBtn.dataset.file = file.file;
980
+ fileChatBtn.innerHTML = `
981
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
982
+ <path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
983
+ </svg>
984
+ `;
985
+ fileChatBtn.addEventListener('click', (e) => {
986
+ e.stopPropagation();
987
+ if (window.chatPanel) {
988
+ window.chatPanel.open({
989
+ fileContext: { file: file.file }
990
+ });
991
+ }
992
+ });
993
+ header.appendChild(fileChatBtn);
858
994
  }
859
995
 
860
996
  // Create diff table
@@ -1077,6 +1213,30 @@ class PRManager {
1077
1213
  this.showCommentForm(row, lineNumber, file, diffPosition, null, side);
1078
1214
  }
1079
1215
  },
1216
+ onChatButtonClick: (_e, row, lineNumber, file, lineData) => {
1217
+ if (!window.chatPanel) return;
1218
+ let startLine = lineNumber;
1219
+ let endLine = null;
1220
+
1221
+ if (this.lineTracker.hasActiveSelection() &&
1222
+ this.lineTracker.rangeSelectionStart.fileName === file) {
1223
+ const range = this.lineTracker.getSelectionRange();
1224
+ startLine = range.start;
1225
+ endLine = range.end;
1226
+ this.lineTracker.clearRangeSelection();
1227
+ }
1228
+
1229
+ window.chatPanel.open({
1230
+ commentContext: {
1231
+ type: 'line',
1232
+ body: null,
1233
+ file: file || '',
1234
+ line_start: startLine,
1235
+ line_end: endLine || startLine,
1236
+ source: 'user'
1237
+ }
1238
+ });
1239
+ },
1080
1240
  onMouseOver: (_e, row, lineNumber, file) => {
1081
1241
  // Check if we have a potential drag start and convert it to an actual drag
1082
1242
  if (this.lineTracker.potentialDragStart && !this.lineTracker.isDraggingRange) {
@@ -1091,6 +1251,7 @@ class PRManager {
1091
1251
  onMouseUp: (_e, row, lineNumber, file) => {
1092
1252
  if (this.lineTracker.potentialDragStart) {
1093
1253
  const start = this.lineTracker.potentialDragStart;
1254
+ const isChat = start.isChat;
1094
1255
  this.lineTracker.potentialDragStart = null;
1095
1256
 
1096
1257
  if (start.lineNumber !== lineNumber || start.fileName !== file) {
@@ -1100,6 +1261,24 @@ class PRManager {
1100
1261
  this.lineTracker.startDragSelection(start.row, start.lineNumber, start.fileName, start.side);
1101
1262
  }
1102
1263
  this.lineTracker.completeDragSelection(row, lineNumber, file);
1264
+
1265
+ // For chat drags, immediately open chat with the selected range
1266
+ if (isChat && this.lineTracker.hasActiveSelection()) {
1267
+ const range = this.lineTracker.getSelectionRange();
1268
+ this.lineTracker.clearRangeSelection();
1269
+ if (window.chatPanel) {
1270
+ window.chatPanel.open({
1271
+ commentContext: {
1272
+ type: 'line',
1273
+ body: null,
1274
+ file: file || '',
1275
+ line_start: range.start,
1276
+ line_end: range.end,
1277
+ source: 'user'
1278
+ }
1279
+ });
1280
+ }
1281
+ }
1103
1282
  }
1104
1283
  } else if (this.lineTracker.isDraggingRange) {
1105
1284
  this.lineTracker.completeDragSelection(row, lineNumber, file);
@@ -1249,11 +1428,11 @@ class PRManager {
1249
1428
  * @returns {Promise<{lines: string[]}|null>} File content with lines array, or null on error
1250
1429
  */
1251
1430
  async fetchFileContent(fileName) {
1252
- if (!this.currentPR) return null;
1431
+ const reviewId = this.currentPR?.id;
1432
+ if (!reviewId) return null;
1253
1433
 
1254
- const { owner, repo, number } = this.currentPR;
1255
1434
  const response = await fetch(
1256
- `/api/file-content-original/${encodeURIComponent(fileName)}?owner=${owner}&repo=${repo}&number=${number}`
1435
+ `/api/reviews/${reviewId}/file-content/${encodeURIComponent(fileName)}`
1257
1436
  );
1258
1437
  const data = await response.json();
1259
1438
 
@@ -1706,6 +1885,40 @@ class PRManager {
1706
1885
  return true;
1707
1886
  }
1708
1887
 
1888
+ /**
1889
+ * Ensure that the given line ranges are visible in the diff view.
1890
+ * For each item, checks if the target line rows exist in the DOM; if not,
1891
+ * calls expandForSuggestion() to expand the gap containing those lines.
1892
+ * @param {Array<{file: string, line_start: number, line_end: number, side: string}>} items
1893
+ */
1894
+ async ensureLinesVisible(items) {
1895
+ for (const item of items) {
1896
+ const { file, line_start, line_end, side } = item;
1897
+ const resolvedSide = (side || 'right').toUpperCase();
1898
+
1899
+ const fileElement = this.findFileElement(file);
1900
+ if (!fileElement) continue;
1901
+
1902
+ // Check if any line in the range is already visible
1903
+ let anyLineVisible = false;
1904
+ const lineRows = fileElement.querySelectorAll('tr');
1905
+ for (let checkLine = line_start; checkLine <= (line_end || line_start); checkLine++) {
1906
+ for (const row of lineRows) {
1907
+ const lineNum = this.getLineNumber(row, resolvedSide);
1908
+ if (lineNum === checkLine) {
1909
+ anyLineVisible = true;
1910
+ break;
1911
+ }
1912
+ }
1913
+ if (anyLineVisible) break;
1914
+ }
1915
+
1916
+ if (!anyLineVisible) {
1917
+ await this.expandForSuggestion(file, line_start, line_end || line_start, resolvedSide);
1918
+ }
1919
+ }
1920
+ }
1921
+
1709
1922
  /**
1710
1923
  * Line range selection methods - delegate to LineTracker
1711
1924
  */
@@ -1791,7 +2004,7 @@ class PRManager {
1791
2004
  let currentText = bodyDiv.dataset.originalMarkdown || '';
1792
2005
 
1793
2006
  if (!currentText) {
1794
- const response = await fetch(`/api/user-comment/${commentId}`);
2007
+ const response = await fetch(`/api/reviews/${this.currentPR.id}/comments/${commentId}`);
1795
2008
  if (response.ok) {
1796
2009
  const data = await response.json();
1797
2010
  currentText = data.body || bodyDiv.textContent.trim();
@@ -1894,7 +2107,7 @@ class PRManager {
1894
2107
  return;
1895
2108
  }
1896
2109
 
1897
- const response = await fetch(`/api/user-comment/${commentId}`, {
2110
+ const response = await fetch(`/api/reviews/${this.currentPR.id}/comments/${commentId}`, {
1898
2111
  method: 'PUT',
1899
2112
  headers: { 'Content-Type': 'application/json' },
1900
2113
  body: JSON.stringify({ body: editedText })
@@ -1920,9 +2133,6 @@ class PRManager {
1920
2133
  if (editFormEl) editFormEl.remove();
1921
2134
  commentDiv.classList.remove('editing-mode');
1922
2135
 
1923
- const timestamp = commentDiv.querySelector('.user-comment-timestamp');
1924
- if (timestamp) timestamp.textContent = new Date().toLocaleString();
1925
-
1926
2136
  // Notify AI Panel about the updated comment body
1927
2137
  if (window.aiPanel?.updateComment) {
1928
2138
  window.aiPanel.updateComment(commentId, { body: editedText });
@@ -1953,11 +2163,6 @@ class PRManager {
1953
2163
  bodyDiv.style.display = '';
1954
2164
  if (editForm) editForm.remove();
1955
2165
  commentDiv.classList.remove('editing-mode');
1956
-
1957
- const timestamp = commentDiv.querySelector('.user-comment-timestamp');
1958
- if (timestamp && timestamp.textContent === 'Editing comment...') {
1959
- timestamp.textContent = 'Draft';
1960
- }
1961
2166
  }
1962
2167
 
1963
2168
  /**
@@ -1970,7 +2175,9 @@ class PRManager {
1970
2175
  */
1971
2176
  async deleteUserComment(commentId) {
1972
2177
  try {
1973
- const response = await fetch(`/api/user-comment/${commentId}`, { method: 'DELETE' });
2178
+ const response = await fetch(`/api/reviews/${this.currentPR.id}/comments/${commentId}`, {
2179
+ method: 'DELETE'
2180
+ });
1974
2181
  if (!response.ok) throw new Error('Failed to delete comment');
1975
2182
 
1976
2183
  const apiResult = await response.json();
@@ -2029,7 +2236,9 @@ class PRManager {
2029
2236
  */
2030
2237
  async restoreUserComment(commentId) {
2031
2238
  try {
2032
- const response = await fetch(`/api/user-comment/${commentId}/restore`, { method: 'PUT' });
2239
+ const response = await fetch(`/api/reviews/${this.currentPR.id}/comments/${commentId}/restore`, {
2240
+ method: 'PUT'
2241
+ });
2033
2242
  if (!response.ok) throw new Error('Failed to restore comment');
2034
2243
 
2035
2244
  // Reload comments to update both the diff view and AI panel
@@ -2091,7 +2300,7 @@ class PRManager {
2091
2300
  if (dialogResult !== 'confirm') return;
2092
2301
 
2093
2302
  try {
2094
- const response = await fetch(`/api/pr/${this.currentPR.owner}/${this.currentPR.repo}/${this.currentPR.number}/user-comments`, {
2303
+ const response = await fetch(`/api/reviews/${this.currentPR.id}/comments`, {
2095
2304
  method: 'DELETE'
2096
2305
  });
2097
2306
 
@@ -2156,7 +2365,7 @@ class PRManager {
2156
2365
 
2157
2366
  try {
2158
2367
  const queryParam = includeDismissed ? '?includeDismissed=true' : '';
2159
- const response = await fetch(`/api/pr/${this.currentPR.owner}/${this.currentPR.repo}/${this.currentPR.number}/user-comments${queryParam}`);
2368
+ const response = await fetch(`/api/reviews/${this.currentPR.id}/comments${queryParam}`);
2160
2369
  if (!response.ok) return;
2161
2370
 
2162
2371
  const data = await response.json();
@@ -2185,6 +2394,16 @@ class PRManager {
2185
2394
  // Clear existing comment rows before re-rendering
2186
2395
  document.querySelectorAll('.user-comment-row').forEach(row => row.remove());
2187
2396
 
2397
+ // Before rendering, ensure all comment target lines are visible
2398
+ // (expand hidden hunks so the line rows exist in the DOM)
2399
+ const lineItems = lineLevelComments.map(c => ({
2400
+ file: c.file,
2401
+ line_start: c.line_start,
2402
+ line_end: c.line_start,
2403
+ side: c.side || 'RIGHT'
2404
+ }));
2405
+ await this.ensureLinesVisible(lineItems);
2406
+
2188
2407
  // Display line-level comments inline with diff (only active comments reach here)
2189
2408
  lineLevelComments.forEach(comment => {
2190
2409
  const fileElement = this.findFileElement(comment.file);
@@ -2243,7 +2462,8 @@ class PRManager {
2243
2462
  // First, check if analysis has been run for this PR and get summary for the selected run
2244
2463
  let analysisHasRun = false;
2245
2464
  try {
2246
- let checkUrl = `/api/pr/${owner}/${repo}/${number}/has-ai-suggestions`;
2465
+ const id = this.currentPR.id;
2466
+ let checkUrl = `/api/reviews/${id}/suggestions/check`;
2247
2467
  if (filterRunId) {
2248
2468
  checkUrl += `?runId=${filterRunId}`;
2249
2469
  }
@@ -2270,7 +2490,7 @@ class PRManager {
2270
2490
  window.aiPanel.setAnalysisState(analysisHasRun ? 'complete' : 'unknown');
2271
2491
  }
2272
2492
 
2273
- let url = `/api/pr/${owner}/${repo}/${number}/ai-suggestions?levels=${filterLevel}`;
2493
+ let url = `/api/reviews/${this.currentPR.id}/suggestions?levels=${filterLevel}`;
2274
2494
  if (filterRunId) {
2275
2495
  url += `&runId=${filterRunId}`;
2276
2496
  }
@@ -2313,10 +2533,6 @@ class PRManager {
2313
2533
  return this.suggestionManager.getFileAndLineInfo(suggestionDiv);
2314
2534
  }
2315
2535
 
2316
- async collapseAISuggestion(suggestionId, suggestionRow, collapsedText, status) {
2317
- return this.suggestionManager.collapseAISuggestion(suggestionId, suggestionRow, collapsedText, status);
2318
- }
2319
-
2320
2536
  getCategoryEmoji(category) {
2321
2537
  return this.suggestionManager.getCategoryEmoji(category);
2322
2538
  }
@@ -2325,14 +2541,103 @@ class PRManager {
2325
2541
  return this.suggestionManager.formatAdoptedComment(text, category);
2326
2542
  }
2327
2543
 
2328
- async createUserCommentFromSuggestion(suggestionId, fileName, lineNumber, suggestionText, suggestionType, suggestionTitle, diffPosition, side) {
2329
- return this.suggestionManager.createUserCommentFromSuggestion(suggestionId, fileName, lineNumber, suggestionText, suggestionType, suggestionTitle, diffPosition, side);
2330
- }
2331
-
2332
2544
  getTypeDescription(type) {
2333
2545
  return this.suggestionManager.getTypeDescription(type);
2334
2546
  }
2335
2547
 
2548
+ /**
2549
+ * Collapse a suggestion div in the UI after adoption.
2550
+ * Handles adding collapsed class, updating text to 'Suggestion adopted',
2551
+ * updating the restore button, and setting hiddenForAdoption flag.
2552
+ * @param {HTMLElement} suggestionRow - The suggestion row element
2553
+ * @param {number|string} suggestionId - Suggestion ID
2554
+ */
2555
+ collapseSuggestionForAdoption(suggestionRow, suggestionId) {
2556
+ if (!suggestionRow) return;
2557
+ const targetDiv = suggestionRow.querySelector(`[data-suggestion-id="${suggestionId}"]`);
2558
+ if (!targetDiv) return;
2559
+ targetDiv.classList.add('collapsed');
2560
+ const collapsedContent = targetDiv.querySelector('.collapsed-text');
2561
+ if (collapsedContent) collapsedContent.textContent = 'Suggestion adopted';
2562
+ const restoreButton = targetDiv.querySelector('.btn-restore');
2563
+ if (restoreButton) {
2564
+ restoreButton.title = 'Show suggestion';
2565
+ const btnText = restoreButton.querySelector('.btn-text');
2566
+ if (btnText) btnText.textContent = 'Show';
2567
+ }
2568
+ targetDiv.dataset.hiddenForAdoption = 'true';
2569
+ }
2570
+
2571
+ /**
2572
+ * Shared helper for adoptAndEditSuggestion and adoptSuggestion.
2573
+ * Performs the /adopt fetch, collapses the suggestion, formats the comment,
2574
+ * and builds the newComment object. Returns { newComment, suggestionRow }
2575
+ * or null on failure. Throws on errors so the caller can handle them.
2576
+ */
2577
+ async _adoptAndBuildComment(suggestionId, suggestionDiv) {
2578
+ const { suggestionText, suggestionType, suggestionTitle } = this.extractSuggestionData(suggestionDiv);
2579
+ const { suggestionRow, lineNumber, fileName, diffPosition, side, isFileLevel } = this.getFileAndLineInfo(suggestionDiv);
2580
+
2581
+ // File-level suggestions are handled by FileCommentManager; signal the caller
2582
+ if (isFileLevel) {
2583
+ return { isFileLevel: true, suggestionText, suggestionType, suggestionTitle, fileName, suggestionRow };
2584
+ }
2585
+
2586
+ // Use the atomic /adopt endpoint which creates the user comment, sets parent_id
2587
+ // linkage, and updates suggestion status to 'adopted' in a single request
2588
+ const reviewId = this.currentPR?.id;
2589
+ const adoptResponse = await fetch(`/api/reviews/${reviewId}/suggestions/${suggestionId}/adopt`, {
2590
+ method: 'POST',
2591
+ headers: { 'Content-Type': 'application/json' }
2592
+ });
2593
+
2594
+ if (!adoptResponse.ok) throw new Error('Failed to adopt suggestion');
2595
+
2596
+ const adoptResult = await adoptResponse.json();
2597
+
2598
+ // Collapse the suggestion in the UI
2599
+ this.collapseSuggestionForAdoption(suggestionRow, suggestionId);
2600
+
2601
+ // Build comment data from the adopt response and suggestion metadata
2602
+ const formattedText = this.formatAdoptedComment(suggestionText, suggestionType);
2603
+ const newComment = {
2604
+ id: adoptResult.userCommentId,
2605
+ file: fileName,
2606
+ line_start: parseInt(lineNumber),
2607
+ body: formattedText,
2608
+ type: suggestionType,
2609
+ title: suggestionTitle,
2610
+ parent_id: suggestionId,
2611
+ diff_position: diffPosition ? parseInt(diffPosition) : null,
2612
+ side: side || 'RIGHT',
2613
+ created_at: new Date().toISOString()
2614
+ };
2615
+
2616
+ return { isFileLevel: false, newComment, suggestionRow };
2617
+ }
2618
+
2619
+ /**
2620
+ * Notify panels and navigator after a successful adoption
2621
+ */
2622
+ _notifyAdoption(suggestionId, newComment) {
2623
+ if (window.aiPanel?.addComment) {
2624
+ window.aiPanel.addComment(newComment);
2625
+ }
2626
+
2627
+ if (this.suggestionNavigator?.suggestions) {
2628
+ const updatedSuggestions = this.suggestionNavigator.suggestions.map(s =>
2629
+ s.id === suggestionId ? { ...s, status: 'adopted' } : s
2630
+ );
2631
+ this.suggestionNavigator.updateSuggestions(updatedSuggestions);
2632
+ }
2633
+
2634
+ if (window.aiPanel) {
2635
+ window.aiPanel.updateFindingStatus(suggestionId, 'adopted');
2636
+ }
2637
+
2638
+ this.updateCommentCount();
2639
+ }
2640
+
2336
2641
  /**
2337
2642
  * Adopt an AI suggestion and open it in edit mode
2338
2643
  */
@@ -2341,54 +2646,27 @@ class PRManager {
2341
2646
  const suggestionDiv = document.querySelector(`[data-suggestion-id="${suggestionId}"]`);
2342
2647
  if (!suggestionDiv) throw new Error('Suggestion element not found');
2343
2648
 
2344
- const { suggestionText, suggestionType, suggestionTitle } = this.extractSuggestionData(suggestionDiv);
2345
- const { suggestionRow, lineNumber, fileName, diffPosition, side, isFileLevel } = this.getFileAndLineInfo(suggestionDiv);
2649
+ const result = await this._adoptAndBuildComment(suggestionId, suggestionDiv);
2346
2650
 
2347
- // File-level suggestions use FileCommentManager for edit-and-adopt
2348
- if (isFileLevel) {
2651
+ if (result.isFileLevel) {
2349
2652
  if (!this.fileCommentManager) throw new Error('FileCommentManager not initialized');
2350
- const zone = this.fileCommentManager.findZoneForFile(fileName);
2351
- if (!zone) throw new Error(`Could not find file comments zone for ${fileName}`);
2653
+ const zone = this.fileCommentManager.findZoneForFile(result.fileName);
2654
+ if (!zone) throw new Error(`Could not find file comments zone for ${result.fileName}`);
2352
2655
 
2353
- // Build suggestion object for FileCommentManager
2354
2656
  const suggestion = {
2355
2657
  id: suggestionId,
2356
- file: fileName,
2357
- body: suggestionText,
2358
- type: suggestionType,
2359
- title: suggestionTitle
2658
+ file: result.fileName,
2659
+ body: result.suggestionText,
2660
+ type: result.suggestionType,
2661
+ title: result.suggestionTitle
2360
2662
  };
2361
2663
 
2362
- // Use editAndAdoptAISuggestion which opens an edit form
2363
2664
  this.fileCommentManager.editAndAdoptAISuggestion(zone, suggestion);
2364
2665
  return;
2365
2666
  }
2366
2667
 
2367
- await this.collapseAISuggestion(suggestionId, suggestionRow, 'Suggestion adopted', 'adopted');
2368
-
2369
- const newComment = await this.createUserCommentFromSuggestion(
2370
- suggestionId, fileName, lineNumber, suggestionText, suggestionType, suggestionTitle, diffPosition, side
2371
- );
2372
-
2373
- this.displayUserCommentInEditMode(newComment, suggestionRow);
2374
-
2375
- // Notify AI Panel about the new adopted comment
2376
- if (window.aiPanel?.addComment) {
2377
- window.aiPanel.addComment(newComment);
2378
- }
2379
-
2380
- if (this.suggestionNavigator?.suggestions) {
2381
- const updatedSuggestions = this.suggestionNavigator.suggestions.map(s =>
2382
- s.id === suggestionId ? { ...s, status: 'adopted' } : s
2383
- );
2384
- this.suggestionNavigator.updateSuggestions(updatedSuggestions);
2385
- }
2386
-
2387
- if (window.aiPanel) {
2388
- window.aiPanel.updateFindingStatus(suggestionId, 'adopted');
2389
- }
2390
-
2391
- this.updateCommentCount();
2668
+ this.displayUserCommentInEditMode(result.newComment, result.suggestionRow);
2669
+ this._notifyAdoption(suggestionId, result.newComment);
2392
2670
  } catch (error) {
2393
2671
  console.error('Error adopting and editing suggestion:', error);
2394
2672
  alert(`Failed to adopt suggestion: ${error.message}`);
@@ -2403,53 +2681,27 @@ class PRManager {
2403
2681
  const suggestionDiv = document.querySelector(`[data-suggestion-id="${suggestionId}"]`);
2404
2682
  if (!suggestionDiv) throw new Error('Suggestion element not found');
2405
2683
 
2406
- const { suggestionText, suggestionType, suggestionTitle } = this.extractSuggestionData(suggestionDiv);
2407
- const { suggestionRow, lineNumber, fileName, diffPosition, side, isFileLevel } = this.getFileAndLineInfo(suggestionDiv);
2684
+ const result = await this._adoptAndBuildComment(suggestionId, suggestionDiv);
2408
2685
 
2409
- // File-level suggestions use FileCommentManager for adoption
2410
- if (isFileLevel) {
2686
+ if (result.isFileLevel) {
2411
2687
  if (!this.fileCommentManager) throw new Error('FileCommentManager not initialized');
2412
- const zone = this.fileCommentManager.findZoneForFile(fileName);
2413
- if (!zone) throw new Error(`Could not find file comments zone for ${fileName}`);
2688
+ const zone = this.fileCommentManager.findZoneForFile(result.fileName);
2689
+ if (!zone) throw new Error(`Could not find file comments zone for ${result.fileName}`);
2414
2690
 
2415
- // Build suggestion object for FileCommentManager
2416
2691
  const suggestion = {
2417
2692
  id: suggestionId,
2418
- file: fileName,
2419
- body: suggestionText,
2420
- type: suggestionType,
2421
- title: suggestionTitle
2693
+ file: result.fileName,
2694
+ body: result.suggestionText,
2695
+ type: result.suggestionType,
2696
+ title: result.suggestionTitle
2422
2697
  };
2423
2698
 
2424
2699
  await this.fileCommentManager.adoptAISuggestion(zone, suggestion);
2425
2700
  return;
2426
2701
  }
2427
2702
 
2428
- await this.collapseAISuggestion(suggestionId, suggestionRow, 'Suggestion adopted', 'adopted');
2429
-
2430
- const newComment = await this.createUserCommentFromSuggestion(
2431
- suggestionId, fileName, lineNumber, suggestionText, suggestionType, suggestionTitle, diffPosition, side
2432
- );
2433
-
2434
- this.displayUserComment(newComment, suggestionRow);
2435
-
2436
- // Notify AI Panel about the new adopted comment
2437
- if (window.aiPanel?.addComment) {
2438
- window.aiPanel.addComment(newComment);
2439
- }
2440
-
2441
- if (this.suggestionNavigator?.suggestions) {
2442
- const updatedSuggestions = this.suggestionNavigator.suggestions.map(s =>
2443
- s.id === suggestionId ? { ...s, status: 'adopted' } : s
2444
- );
2445
- this.suggestionNavigator.updateSuggestions(updatedSuggestions);
2446
- }
2447
-
2448
- if (window.aiPanel) {
2449
- window.aiPanel.updateFindingStatus(suggestionId, 'adopted');
2450
- }
2451
-
2452
- this.updateCommentCount();
2703
+ this.displayUserComment(result.newComment, result.suggestionRow);
2704
+ this._notifyAdoption(suggestionId, result.newComment);
2453
2705
  } catch (error) {
2454
2706
  console.error('Error adopting suggestion:', error);
2455
2707
  alert(`Failed to adopt suggestion: ${error.message}`);
@@ -2480,7 +2732,7 @@ class PRManager {
2480
2732
  return;
2481
2733
  }
2482
2734
 
2483
- const response = await fetch(`/api/ai-suggestion/${suggestionId}/status`, {
2735
+ const response = await fetch(`/api/reviews/${this.currentPR.id}/suggestions/${suggestionId}/status`, {
2484
2736
  method: 'POST',
2485
2737
  headers: { 'Content-Type': 'application/json' },
2486
2738
  body: JSON.stringify({ status: 'dismissed' })
@@ -2536,7 +2788,7 @@ class PRManager {
2536
2788
  return;
2537
2789
  }
2538
2790
 
2539
- const response = await fetch(`/api/ai-suggestion/${suggestionId}/status`, {
2791
+ const response = await fetch(`/api/reviews/${this.currentPR.id}/suggestions/${suggestionId}/status`, {
2540
2792
  method: 'POST',
2541
2793
  headers: { 'Content-Type': 'application/json' },
2542
2794
  body: JSON.stringify({ status: 'active' })
@@ -2815,15 +3067,10 @@ class PRManager {
2815
3067
  return;
2816
3068
  }
2817
3069
 
2818
- // Determine the correct API endpoint based on mode
3070
+ // Use unified review comments API (works for both PR and local mode)
3071
+ const reviewId = pr.id;
2819
3072
  let response;
2820
- if (window.PAIR_REVIEW_LOCAL_MODE && window.localManager?.reviewId) {
2821
- // Local mode - use local API endpoint
2822
- response = await fetch(`/api/local/${window.localManager.reviewId}/user-comments`);
2823
- } else {
2824
- // PR mode - use PR API endpoint
2825
- response = await fetch(`/api/pr/${pr.owner}/${pr.repo}/${pr.number}/user-comments`);
2826
- }
3073
+ response = await fetch(`/api/reviews/${reviewId}/comments`);
2827
3074
 
2828
3075
  if (!response.ok) {
2829
3076
  throw new Error('Failed to load comments');
@@ -2924,6 +3171,11 @@ class PRManager {
2924
3171
  generated: file.generated || false,
2925
3172
  renamed: file.renamed || false,
2926
3173
  renamedFrom: file.renamedFrom || null,
3174
+ contextFile: file.contextFile || false,
3175
+ contextId: file.contextId || null,
3176
+ label: file.label || null,
3177
+ lineStart: file.lineStart || null,
3178
+ lineEnd: file.lineEnd || null,
2927
3179
  });
2928
3180
  });
2929
3181
 
@@ -2938,6 +3190,9 @@ class PRManager {
2938
3190
  const fileListContainer = document.getElementById('file-list');
2939
3191
  if (!fileListContainer) return;
2940
3192
 
3193
+ // Store diff-only files for merging with context files later
3194
+ this.diffFiles = files.filter(f => !f.contextFile);
3195
+
2941
3196
  // Update sidebar file count badge
2942
3197
  const fileCountEl = document.getElementById('sidebar-file-count');
2943
3198
  if (fileCountEl) {
@@ -3008,6 +3263,7 @@ class PRManager {
3008
3263
  item.dataset.status = file.status;
3009
3264
 
3010
3265
  if (file.generated) item.classList.add('generated');
3266
+ if (file.contextFile) item.classList.add('context-file-item');
3011
3267
  if (file.renamed && file.renamedFrom) {
3012
3268
  item.title = `Renamed from: ${file.renamedFrom}`;
3013
3269
  const renameIcon = document.createElement('span');
@@ -3023,7 +3279,13 @@ class PRManager {
3023
3279
  const changes = document.createElement('span');
3024
3280
  changes.className = 'file-changes';
3025
3281
 
3026
- if (file.binary) {
3282
+ if (file.contextFile) {
3283
+ const badge = document.createElement('span');
3284
+ badge.className = 'context-badge';
3285
+ badge.textContent = 'CONTEXT';
3286
+ if (file.label) badge.title = file.label;
3287
+ changes.appendChild(badge);
3288
+ } else if (file.binary) {
3027
3289
  changes.textContent = 'BIN';
3028
3290
  } else {
3029
3291
  if (file.additions > 0) {
@@ -3045,7 +3307,11 @@ class PRManager {
3045
3307
 
3046
3308
  item.addEventListener('click', (e) => {
3047
3309
  e.preventDefault();
3048
- this.scrollToFile(file.fullPath);
3310
+ if (file.contextFile) {
3311
+ this.scrollToContextFile(file.fullPath, file.lineStart, file.contextId);
3312
+ } else {
3313
+ this.scrollToFile(file.fullPath);
3314
+ }
3049
3315
  this.setActiveFileItem(file.fullPath);
3050
3316
  });
3051
3317
 
@@ -3377,8 +3643,9 @@ class PRManager {
3377
3643
  if (!this.currentPR) return;
3378
3644
 
3379
3645
  try {
3380
- const { owner, repo, number } = this.currentPR;
3381
- const response = await fetch(`/api/pr/${owner}/${repo}/${number}/analysis-status`);
3646
+ const reviewId = this.currentPR.id;
3647
+ if (!reviewId) return;
3648
+ const response = await fetch(`/api/reviews/${reviewId}/analyses/status`);
3382
3649
 
3383
3650
  if (!response.ok) {
3384
3651
  console.warn('Could not check analysis status:', response.statusText);
@@ -3664,7 +3931,7 @@ class PRManager {
3664
3931
  // Determine endpoint and body based on whether this is a council analysis
3665
3932
  let analyzeUrl, analyzeBody;
3666
3933
  if (config.isCouncil) {
3667
- analyzeUrl = `/api/analyze/council/${owner}/${repo}/${number}`;
3934
+ analyzeUrl = `/api/pr/${owner}/${repo}/${number}/analyses/council`;
3668
3935
  analyzeBody = {
3669
3936
  councilId: config.councilId || undefined,
3670
3937
  councilConfig: config.councilConfig || undefined,
@@ -3672,7 +3939,7 @@ class PRManager {
3672
3939
  customInstructions: config.customInstructions || null
3673
3940
  };
3674
3941
  } else {
3675
- analyzeUrl = `/api/analyze/${owner}/${repo}/${number}`;
3942
+ analyzeUrl = `/api/pr/${owner}/${repo}/${number}/analyses`;
3676
3943
  analyzeBody = {
3677
3944
  provider: config.provider || 'claude',
3678
3945
  model: config.model || 'opus',
@@ -3839,6 +4106,589 @@ class PRManager {
3839
4106
  }
3840
4107
  }
3841
4108
  }
4109
+
4110
+ // ─── Context Files ──────────────────────────────────────────────
4111
+
4112
+ /**
4113
+ * Load context files for the current review and render them in the diff panel.
4114
+ * Called after renderDiff() and on SSE context_files_changed events.
4115
+ */
4116
+ async loadContextFiles() {
4117
+ const reviewId = this.currentPR?.id;
4118
+ if (!reviewId) return;
4119
+
4120
+ try {
4121
+ const response = await fetch(`/api/reviews/${reviewId}/context-files`);
4122
+ if (!response.ok) return;
4123
+
4124
+ const data = await response.json();
4125
+ const newFiles = data.contextFiles || [];
4126
+
4127
+ const oldIds = new Set((this.contextFiles || []).map(f => f.id));
4128
+ const newIds = new Set(newFiles.map(f => f.id));
4129
+
4130
+ // Remove only deleted context files (handles both standalone and merged wrappers)
4131
+ for (const old of this.contextFiles || []) {
4132
+ if (!newIds.has(old.id)) {
4133
+ const el = document.querySelector(`[data-context-id="${old.id}"]`);
4134
+ if (!el) continue;
4135
+ if (el.classList.contains('context-file')) {
4136
+ // Standalone wrapper (legacy) — remove entirely
4137
+ el.remove();
4138
+ } else {
4139
+ // Chunk tbody within a merged wrapper
4140
+ const wrapper = el.closest('.context-file');
4141
+ // Also remove adjacent separator tbody if present
4142
+ const prevSib = el.previousElementSibling;
4143
+ const nextSib = el.nextElementSibling;
4144
+ if (prevSib && prevSib.classList.contains('context-chunk-separator')) {
4145
+ prevSib.remove();
4146
+ } else if (nextSib && nextSib.classList.contains('context-chunk-separator')) {
4147
+ nextSib.remove();
4148
+ }
4149
+ el.remove();
4150
+ // If no more chunks remain, remove the wrapper too
4151
+ if (wrapper && !wrapper.querySelector('.context-chunk')) {
4152
+ wrapper.remove();
4153
+ }
4154
+ }
4155
+ }
4156
+ }
4157
+
4158
+ // Add only new context files
4159
+ let newFilesRendered = false;
4160
+ for (const cf of newFiles) {
4161
+ if (!oldIds.has(cf.id)) {
4162
+ await this.renderContextFile(cf);
4163
+ newFilesRendered = true;
4164
+ }
4165
+ }
4166
+
4167
+ this.contextFiles = newFiles;
4168
+
4169
+ // Rebuild sidebar with context files interleaved in natural path order
4170
+ this.rebuildFileListWithContext();
4171
+
4172
+ // Re-anchor comments after new context files are rendered so that
4173
+ // comments targeting lines in these files find their DOM targets.
4174
+ // loadUserComments() is idempotent (clears existing comment rows first).
4175
+ if (newFilesRendered) {
4176
+ const includeDismissed = window.aiPanel?.showDismissedComments || false;
4177
+ await this.loadUserComments(includeDismissed);
4178
+ }
4179
+ } catch (error) {
4180
+ console.error('Error loading context files:', error);
4181
+ }
4182
+ }
4183
+
4184
+ /**
4185
+ * Rebuild the sidebar file list with context files interleaved in natural path order.
4186
+ * Merges stored diff files with current context files and re-renders the sidebar.
4187
+ * Delegates to the shared FileListMerger module for the merge/sort logic.
4188
+ */
4189
+ rebuildFileListWithContext() {
4190
+ const { mergeFileListWithContext } = window.FileListMerger || {};
4191
+ if (!mergeFileListWithContext) {
4192
+ console.warn('FileListMerger not loaded - cannot rebuild file list with context');
4193
+ return;
4194
+ }
4195
+ const merged = mergeFileListWithContext(this.diffFiles, this.contextFiles);
4196
+ this.updateFileList(merged);
4197
+ }
4198
+
4199
+ /**
4200
+ * Build a context chunk tbody with line rows for a context file range.
4201
+ * @param {Object} data - { lines: string[] } from fetchFileContent
4202
+ * @param {Object} contextFile - { id, file, line_start, line_end }
4203
+ * @returns {HTMLElement} tbody element with class context-chunk
4204
+ * @private
4205
+ */
4206
+ _buildContextChunkTbody(data, contextFile) {
4207
+ const tbody = document.createElement('tbody');
4208
+ tbody.className = 'd2h-diff-tbody context-chunk';
4209
+ tbody.dataset.contextId = contextFile.id;
4210
+ tbody.dataset.lineStart = contextFile.line_start;
4211
+
4212
+ // Chunk header row with range label and per-chunk dismiss button
4213
+ const headerRow = document.createElement('tr');
4214
+ headerRow.className = 'context-chunk-header';
4215
+ const lineNumTd = document.createElement('td');
4216
+ lineNumTd.className = 'd2h-code-linenumber';
4217
+ headerRow.appendChild(lineNumTd);
4218
+ const contentTd = document.createElement('td');
4219
+ contentTd.className = 'd2h-code-side-line';
4220
+ contentTd.colSpan = 3;
4221
+ const rangeLabel = document.createElement('span');
4222
+ rangeLabel.className = 'context-range-label';
4223
+ const lineEnd = Math.min(contextFile.line_end, data.lines.length);
4224
+ rangeLabel.textContent = `Lines ${contextFile.line_start}\u2013${lineEnd}`;
4225
+ contentTd.appendChild(rangeLabel);
4226
+ const chunkDismiss = document.createElement('button');
4227
+ chunkDismiss.className = 'context-chunk-dismiss';
4228
+ chunkDismiss.title = 'Remove this range';
4229
+ chunkDismiss.innerHTML = '\u00d7';
4230
+ chunkDismiss.addEventListener('click', (e) => {
4231
+ e.stopPropagation();
4232
+ this.removeContextFile(contextFile.id);
4233
+ });
4234
+ contentTd.appendChild(chunkDismiss);
4235
+ headerRow.appendChild(contentTd);
4236
+ tbody.appendChild(headerRow);
4237
+
4238
+ const lineStart = contextFile.line_start;
4239
+ const clampedEnd = Math.min(contextFile.line_end, data.lines.length);
4240
+
4241
+ // Add expand-up gap row if there are lines above the context range
4242
+ if (lineStart > 1) {
4243
+ const gapAboveSize = lineStart - 1;
4244
+ const gapAbove = window.HunkParser.createGapRowElement(
4245
+ contextFile.file,
4246
+ 1, // startLine (old coords)
4247
+ lineStart - 1, // endLine (old coords)
4248
+ gapAboveSize,
4249
+ 'above',
4250
+ this.expandGapContext.bind(this),
4251
+ 1 // startLineNew (same as old for context files — no diff offset)
4252
+ );
4253
+ tbody.appendChild(gapAbove);
4254
+ }
4255
+
4256
+ for (let i = lineStart; i <= clampedEnd; i++) {
4257
+ const lineData = {
4258
+ type: 'context',
4259
+ oldNumber: i,
4260
+ newNumber: i,
4261
+ content: ' ' + (data.lines[i - 1] || '')
4262
+ };
4263
+ this.renderDiffLine(tbody, lineData, contextFile.file, null);
4264
+ }
4265
+
4266
+ // Add expand-down gap row if there are lines below the context range
4267
+ const totalLines = data.lines.length;
4268
+ if (clampedEnd < totalLines) {
4269
+ const gapBelowSize = totalLines - clampedEnd;
4270
+ const gapBelow = window.HunkParser.createGapRowElement(
4271
+ contextFile.file,
4272
+ clampedEnd + 1, // startLine (old coords)
4273
+ totalLines, // endLine (old coords)
4274
+ gapBelowSize,
4275
+ 'below',
4276
+ this.expandGapContext.bind(this),
4277
+ clampedEnd + 1 // startLineNew (same as old)
4278
+ );
4279
+ tbody.appendChild(gapBelow);
4280
+ }
4281
+
4282
+ return tbody;
4283
+ }
4284
+
4285
+ /**
4286
+ * Insert a chunk tbody into an existing table in sorted position by line_start.
4287
+ * Adds a visual separator tbody between non-contiguous ranges.
4288
+ * @param {HTMLElement} table - the d2h-diff-table
4289
+ * @param {HTMLElement} newTbody - the context-chunk tbody to insert
4290
+ * @private
4291
+ */
4292
+ _insertChunkSorted(table, newTbody) {
4293
+ const newStart = parseInt(newTbody.dataset.lineStart, 10);
4294
+ const existingChunks = [...table.querySelectorAll('tbody.context-chunk')];
4295
+
4296
+ // Find insertion point
4297
+ let insertBeforeChunk = null;
4298
+ for (const chunk of existingChunks) {
4299
+ const chunkStart = parseInt(chunk.dataset.lineStart, 10);
4300
+ if (chunkStart > newStart) {
4301
+ insertBeforeChunk = chunk;
4302
+ break;
4303
+ }
4304
+ }
4305
+
4306
+ // Determine the element to insert before (including any separator before it)
4307
+ if (insertBeforeChunk) {
4308
+ const prevSibling = insertBeforeChunk.previousElementSibling;
4309
+ const hasSepBefore = prevSibling && prevSibling.classList.contains('context-chunk-separator');
4310
+ if (hasSepBefore) {
4311
+ table.insertBefore(newTbody, prevSibling);
4312
+ const sep = this._createChunkSeparator();
4313
+ table.insertBefore(sep, newTbody);
4314
+ } else {
4315
+ table.insertBefore(newTbody, insertBeforeChunk);
4316
+ const sep = this._createChunkSeparator();
4317
+ table.insertBefore(sep, insertBeforeChunk);
4318
+ }
4319
+ } else {
4320
+ // Append after the last chunk — add separator before if there are existing chunks
4321
+ if (existingChunks.length > 0) {
4322
+ const sep = this._createChunkSeparator();
4323
+ table.appendChild(sep);
4324
+ }
4325
+ table.appendChild(newTbody);
4326
+ }
4327
+ }
4328
+
4329
+ /**
4330
+ * Create a visual separator tbody between context chunks.
4331
+ * @returns {HTMLElement} tbody with a single separator row
4332
+ * @private
4333
+ */
4334
+ _createChunkSeparator() {
4335
+ const sep = document.createElement('tbody');
4336
+ sep.className = 'context-chunk-separator';
4337
+ const row = document.createElement('tr');
4338
+ row.className = 'context-chunk-separator-row';
4339
+ const td = document.createElement('td');
4340
+ td.colSpan = 4;
4341
+ td.className = 'd2h-code-side-line context-chunk-separator-cell';
4342
+ row.appendChild(td);
4343
+ sep.appendChild(row);
4344
+ return sep;
4345
+ }
4346
+
4347
+ /**
4348
+ * Render a single context file range in the diff panel.
4349
+ * Merges ranges for the same file into a single wrapper with multiple chunk tbodies.
4350
+ * @param {Object} contextFile - { id, review_id, file, line_start, line_end, label }
4351
+ */
4352
+ async renderContextFile(contextFile) {
4353
+ const diffContainer = document.getElementById('diff-container');
4354
+ if (!diffContainer) return;
4355
+
4356
+ // Fetch file content
4357
+ const data = await this.fetchFileContent(contextFile.file);
4358
+ if (!data || !data.lines) return;
4359
+
4360
+ // Check if a wrapper already exists for this file
4361
+ const existing = diffContainer.querySelector(
4362
+ `.d2h-file-wrapper.context-file[data-file-name="${CSS.escape(contextFile.file)}"]`
4363
+ );
4364
+
4365
+ if (existing) {
4366
+ // Merge into existing wrapper — add a new chunk tbody
4367
+ const table = existing.querySelector('.d2h-diff-table');
4368
+ if (!table) return;
4369
+ const newTbody = this._buildContextChunkTbody(data, contextFile);
4370
+ this._insertChunkSorted(table, newTbody);
4371
+ return;
4372
+ }
4373
+
4374
+ // No existing wrapper — create a new one
4375
+ const wrapper = document.createElement('div');
4376
+ wrapper.className = 'd2h-file-wrapper context-file';
4377
+ wrapper.dataset.fileName = contextFile.file;
4378
+
4379
+ // Build file header — matches regular diff headers (chevron, viewed, comment btn, chat btn)
4380
+ const header = document.createElement('div');
4381
+ header.className = 'd2h-file-header context-file-header';
4382
+
4383
+ // Chevron toggle for expand/collapse
4384
+ const chevronBtn = document.createElement('button');
4385
+ chevronBtn.className = 'file-collapse-toggle';
4386
+ chevronBtn.title = 'Collapse file';
4387
+ chevronBtn.innerHTML = window.DiffRenderer.CHEVRON_DOWN_ICON;
4388
+ chevronBtn.addEventListener('click', (e) => {
4389
+ e.stopPropagation();
4390
+ this.toggleFileCollapse(contextFile.file);
4391
+ });
4392
+ header.appendChild(chevronBtn);
4393
+
4394
+ const fileName = document.createElement('span');
4395
+ fileName.className = 'd2h-file-name';
4396
+ fileName.textContent = contextFile.file;
4397
+ header.appendChild(fileName);
4398
+
4399
+ const contextLabel = document.createElement('span');
4400
+ contextLabel.className = 'context-badge';
4401
+ contextLabel.textContent = 'CONTEXT';
4402
+ if (contextFile.label) contextLabel.title = contextFile.label;
4403
+ header.appendChild(contextLabel);
4404
+
4405
+ // Viewed checkbox (right-aligned group start)
4406
+ const viewedLabel = document.createElement('label');
4407
+ viewedLabel.className = 'file-viewed-label';
4408
+ viewedLabel.title = 'Mark file as viewed';
4409
+ const viewedCheckbox = document.createElement('input');
4410
+ viewedCheckbox.type = 'checkbox';
4411
+ viewedCheckbox.className = 'file-viewed-checkbox';
4412
+ viewedCheckbox.checked = this.viewedFiles.has(contextFile.file);
4413
+ viewedCheckbox.addEventListener('change', (e) => {
4414
+ e.stopPropagation();
4415
+ this.toggleFileViewed(contextFile.file, viewedCheckbox.checked);
4416
+ });
4417
+ viewedLabel.appendChild(viewedCheckbox);
4418
+ viewedLabel.appendChild(document.createTextNode('Viewed'));
4419
+ header.appendChild(viewedLabel);
4420
+
4421
+ // File comment button
4422
+ if (this.fileCommentManager) {
4423
+ const fileCommentsZone = this.fileCommentManager.createFileCommentsZone(contextFile.file);
4424
+ wrapper._fileCommentsZone = fileCommentsZone;
4425
+
4426
+ const fileCommentBtn = document.createElement('button');
4427
+ fileCommentBtn.className = 'file-header-comment-btn';
4428
+ fileCommentBtn.title = 'Add file comment';
4429
+ fileCommentBtn.dataset.file = contextFile.file;
4430
+ fileCommentBtn.innerHTML = `
4431
+ <svg class="comment-icon-outline" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
4432
+ <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.25Zm1.5 0v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25H2.75a.25.25 0 0 0-.25.25Z"/>
4433
+ </svg>
4434
+ <svg class="comment-icon-filled" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="display:none">
4435
+ <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"/>
4436
+ </svg>
4437
+ `;
4438
+ fileCommentBtn.addEventListener('click', (e) => {
4439
+ e.stopPropagation();
4440
+ this.fileCommentManager.showCommentForm(fileCommentsZone, contextFile.file);
4441
+ });
4442
+ header.appendChild(fileCommentBtn);
4443
+ fileCommentsZone.headerButton = fileCommentBtn;
4444
+ }
4445
+
4446
+ // Chat/discussion button
4447
+ const fileChatBtn = document.createElement('button');
4448
+ fileChatBtn.className = 'file-header-chat-btn';
4449
+ fileChatBtn.title = 'Chat about file';
4450
+ fileChatBtn.dataset.file = contextFile.file;
4451
+ fileChatBtn.innerHTML = `
4452
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
4453
+ <path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
4454
+ </svg>
4455
+ `;
4456
+ fileChatBtn.addEventListener('click', (e) => {
4457
+ e.stopPropagation();
4458
+ if (window.chatPanel) {
4459
+ window.chatPanel.open({ fileContext: { file: contextFile.file } });
4460
+ }
4461
+ });
4462
+ header.appendChild(fileChatBtn);
4463
+
4464
+ // Dismiss button — removes ALL context ranges for this file
4465
+ const dismissBtn = document.createElement('button');
4466
+ dismissBtn.className = 'context-file-dismiss';
4467
+ dismissBtn.title = 'Remove context file';
4468
+ dismissBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>`;
4469
+ dismissBtn.addEventListener('click', (e) => {
4470
+ e.stopPropagation();
4471
+ // Remove all context ranges for this file
4472
+ const fileWrapper = e.target.closest('.context-file');
4473
+ if (!fileWrapper) return;
4474
+ const chunkIds = [...fileWrapper.querySelectorAll('tbody.context-chunk[data-context-id]')]
4475
+ .map(tb => tb.dataset.contextId);
4476
+ if (chunkIds.length === 0) return;
4477
+ // Remove all ranges — fire sequentially to avoid race conditions
4478
+ const removeAll = async () => {
4479
+ for (const cid of chunkIds) {
4480
+ await this.removeContextFile(cid);
4481
+ }
4482
+ };
4483
+ removeAll();
4484
+ });
4485
+ header.appendChild(dismissBtn);
4486
+
4487
+ // Click anywhere on header to toggle collapse (except interactive controls)
4488
+ header.addEventListener('click', (e) => {
4489
+ if (e.target.closest('.file-viewed-label') || e.target.closest('.file-collapse-toggle') ||
4490
+ e.target.closest('.file-header-comment-btn') || e.target.closest('.file-header-chat-btn') ||
4491
+ e.target.closest('.context-file-dismiss')) {
4492
+ return;
4493
+ }
4494
+ this.toggleFileCollapse(contextFile.file);
4495
+ });
4496
+
4497
+ wrapper.appendChild(header);
4498
+
4499
+ // Insert file comments zone between header and diff content
4500
+ if (wrapper._fileCommentsZone) {
4501
+ wrapper.appendChild(wrapper._fileCommentsZone);
4502
+ }
4503
+
4504
+ // Build code table with the first chunk
4505
+ const table = document.createElement('table');
4506
+ table.className = 'd2h-diff-table';
4507
+ const tbody = this._buildContextChunkTbody(data, contextFile);
4508
+ table.appendChild(tbody);
4509
+ wrapper.appendChild(table);
4510
+
4511
+ // Insert in sorted path order among existing file wrappers
4512
+ const allWrappers = [...diffContainer.querySelectorAll('.d2h-file-wrapper')];
4513
+ const insertBefore = allWrappers.find(w => w.dataset.fileName > contextFile.file);
4514
+ if (insertBefore) {
4515
+ diffContainer.insertBefore(wrapper, insertBefore);
4516
+ } else {
4517
+ diffContainer.appendChild(wrapper);
4518
+ }
4519
+ }
4520
+
4521
+ /**
4522
+ * Remove a context file by ID.
4523
+ * @param {number} contextFileId
4524
+ */
4525
+ async removeContextFile(contextFileId) {
4526
+ const reviewId = this.currentPR?.id;
4527
+ if (!reviewId) return;
4528
+
4529
+ try {
4530
+ const resp = await fetch(`/api/reviews/${reviewId}/context-files/${contextFileId}`, {
4531
+ method: 'DELETE',
4532
+ headers: { 'Content-Type': 'application/json' }
4533
+ });
4534
+ if (!resp.ok) {
4535
+ console.error('Failed to remove context file:', resp.status);
4536
+ return;
4537
+ }
4538
+ // Refresh immediately — SSE self-echo is suppressed by the client ID filter
4539
+ await this.loadContextFiles();
4540
+ } catch (error) {
4541
+ console.error('Error removing context file:', error);
4542
+ }
4543
+ }
4544
+
4545
+ /**
4546
+ * Scroll to a context file (or diff file) in the diff panel.
4547
+ * @param {string} file - File path
4548
+ * @param {number} [lineStart] - Optional line number to highlight
4549
+ */
4550
+ scrollToContextFile(file, lineStart, contextId) {
4551
+ // Use contextId to find a specific chunk tbody within a merged wrapper,
4552
+ // or fall back to a standalone wrapper or the file-level wrapper.
4553
+ let target;
4554
+ if (contextId) {
4555
+ // First try finding a specific chunk tbody (merged wrapper case)
4556
+ const chunk = document.querySelector(`.context-chunk[data-context-id="${CSS.escape(contextId)}"]`);
4557
+ if (chunk) {
4558
+ target = chunk;
4559
+ } else {
4560
+ // Fallback: legacy standalone wrapper with data-context-id on the wrapper itself
4561
+ target = document.querySelector(`.d2h-file-wrapper.context-file[data-context-id="${CSS.escape(contextId)}"]`);
4562
+ }
4563
+ }
4564
+ if (!target) {
4565
+ target = document.querySelector(`.d2h-file-wrapper.context-file[data-file-name="${CSS.escape(file)}"]`);
4566
+ }
4567
+ if (!target) return;
4568
+
4569
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
4570
+
4571
+ if (lineStart) {
4572
+ // Search for the line row within the wrapper (not just the target chunk)
4573
+ const wrapper = target.closest('.d2h-file-wrapper') || target;
4574
+ // Brief delay to let scroll settle, then highlight the target line
4575
+ setTimeout(() => {
4576
+ const row = wrapper.querySelector(`tr[data-line-number="${lineStart}"]`);
4577
+ if (row) {
4578
+ row.classList.remove('chat-line-highlight');
4579
+ void row.offsetWidth;
4580
+ row.classList.add('chat-line-highlight');
4581
+ row.addEventListener('animationend', () => {
4582
+ row.classList.remove('chat-line-highlight');
4583
+ }, { once: true });
4584
+ }
4585
+ }, 400);
4586
+ }
4587
+ }
4588
+
4589
+ async ensureContextFile(file, lineStart = null, lineEnd = null) {
4590
+ // 1. Guard: no review loaded
4591
+ if (!this.currentPR?.id) return null;
4592
+
4593
+ // 2. Check diff files
4594
+ if (this.diffFiles?.some(f => f.file === file)) {
4595
+ return { type: 'diff' };
4596
+ }
4597
+
4598
+ // 3. Compute line range values up front (used by both existing-check and POST)
4599
+ let lineStartVal, lineEndVal;
4600
+ if (lineStart == null && lineEnd == null) {
4601
+ lineStartVal = 1;
4602
+ lineEndVal = 100;
4603
+ } else if (lineEnd == null) {
4604
+ lineStartVal = lineStart;
4605
+ lineEndVal = lineStart + 49;
4606
+ } else {
4607
+ lineStartVal = lineStart;
4608
+ lineEndVal = Math.min(lineEnd, lineStart + 499);
4609
+ }
4610
+
4611
+ // 4. Check existing context files — expand range if needed
4612
+ const existingEntries = this.contextFiles?.filter(cf => cf.file === file) || [];
4613
+ if (existingEntries.length > 0 && lineStart != null) {
4614
+ const covering = existingEntries.find(cf =>
4615
+ cf.line_start <= lineStartVal && cf.line_end >= lineEndVal
4616
+ );
4617
+ if (covering) {
4618
+ return { type: 'context', contextFile: covering };
4619
+ }
4620
+
4621
+ const overlapping = existingEntries.find(cf =>
4622
+ cf.line_start <= lineEndVal && cf.line_end >= lineStartVal
4623
+ );
4624
+ if (overlapping) {
4625
+ const newStart = Math.min(overlapping.line_start, lineStartVal);
4626
+ let newEnd = Math.max(overlapping.line_end, lineEndVal);
4627
+ if (newEnd - newStart + 1 > 500) {
4628
+ newEnd = newStart + 499;
4629
+ }
4630
+ const reviewId = this.currentPR.id;
4631
+ try {
4632
+ const resp = await fetch(`/api/reviews/${reviewId}/context-files/${overlapping.id}`, {
4633
+ method: 'PATCH',
4634
+ headers: { 'Content-Type': 'application/json' },
4635
+ body: JSON.stringify({ line_start: newStart, line_end: newEnd })
4636
+ });
4637
+ if (resp.ok) {
4638
+ // Evict stale entries for this file so loadContextFiles sees
4639
+ // them as new IDs and triggers a fresh render.
4640
+ const staleFile = overlapping.file;
4641
+ this.contextFiles = (this.contextFiles || []).filter(cf => cf.file !== staleFile);
4642
+ // Remove the file wrapper from the DOM so chunks are re-created
4643
+ const staleWrapper = document.querySelector(
4644
+ `.d2h-file-wrapper.context-file[data-file-name="${CSS.escape(staleFile)}"]`
4645
+ );
4646
+ if (staleWrapper) staleWrapper.remove();
4647
+
4648
+ await this.loadContextFiles();
4649
+ const updated = this.contextFiles?.find(cf => cf.id === overlapping.id);
4650
+ return { type: 'context', contextFile: updated || overlapping, expanded: true };
4651
+ }
4652
+ } catch (err) {
4653
+ console.error('Error expanding context file range:', err);
4654
+ }
4655
+ }
4656
+ } else if (existingEntries.length > 0) {
4657
+ return { type: 'context', contextFile: existingEntries[0] };
4658
+ }
4659
+
4660
+ // 5. POST to add context file
4661
+ const reviewId = this.currentPR.id;
4662
+ try {
4663
+ const resp = await fetch(`/api/reviews/${reviewId}/context-files`, {
4664
+ method: 'POST',
4665
+ headers: { 'Content-Type': 'application/json' },
4666
+ body: JSON.stringify({ file, line_start: lineStartVal, line_end: lineEndVal })
4667
+ });
4668
+
4669
+ if (resp.status === 201) {
4670
+ // 6. Reload context files to render
4671
+ await this.loadContextFiles();
4672
+ const added = this.contextFiles?.find(cf => cf.file === file);
4673
+ return { type: 'context', contextFile: added || null };
4674
+ }
4675
+
4676
+ if (resp.status === 400) {
4677
+ const data = await resp.json().catch(() => ({}));
4678
+ if (data.error?.includes('already part of the diff')) {
4679
+ return { type: 'diff' };
4680
+ }
4681
+ }
4682
+
4683
+ // 7. Other errors
4684
+ console.error('Failed to add context file:', resp.status);
4685
+ return null;
4686
+ } catch (err) {
4687
+ console.error('Error adding context file:', err);
4688
+ return null;
4689
+ }
4690
+ }
4691
+
3842
4692
  }
3843
4693
 
3844
4694
  // Initialize PR manager when DOM is loaded (browser environment only)