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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,7 @@ async function main() {
26
26
  : 'inherit';
27
27
 
28
28
  // Spawn the main process with arguments
29
- const app = spawn('node', [mainPath, ...args], {
29
+ const app = spawn(process.execPath, [mainPath, ...args], {
30
30
  stdio: stdioOption
31
31
  });
32
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
package/public/css/pr.css CHANGED
@@ -6362,12 +6362,29 @@ body:not([data-theme="dark"]) .theme-icon-light {
6362
6362
  }
6363
6363
 
6364
6364
  /* Small icon buttons in toolbar have reduced dimensions */
6365
- .toolbar-actions .btn-sm.btn-icon {
6365
+ .toolbar-actions .btn-sm.btn-icon,
6366
+ .toolbar-meta .btn-sm.btn-icon {
6366
6367
  padding: 6px;
6367
6368
  width: 32px;
6368
6369
  height: 32px;
6369
6370
  }
6370
6371
 
6372
+ /* Icon buttons inside toolbar-meta need the same visual treatment as toolbar-actions */
6373
+ .toolbar-meta .btn-icon {
6374
+ background: var(--color-bg-secondary);
6375
+ border: 1px solid var(--color-border-primary);
6376
+ color: var(--color-text-secondary);
6377
+ border-radius: var(--radius-sm);
6378
+ cursor: pointer;
6379
+ transition: all var(--transition-fast);
6380
+ }
6381
+
6382
+ .toolbar-meta .btn-icon:hover {
6383
+ background: var(--color-bg-tertiary);
6384
+ color: var(--color-text-primary);
6385
+ border-color: var(--color-border-secondary);
6386
+ }
6387
+
6371
6388
  /* ============================================
6372
6389
  Analysis Progress Dots
6373
6390
  ============================================ */
@@ -6962,17 +6979,96 @@ body.resizing * {
6962
6979
  }
6963
6980
 
6964
6981
  /* Dark theme toolbar button overrides */
6965
- [data-theme="dark"] .toolbar-actions .btn-icon {
6982
+ [data-theme="dark"] .toolbar-actions .btn-icon,
6983
+ [data-theme="dark"] .toolbar-meta .btn-icon {
6966
6984
  background: var(--color-bg-tertiary);
6967
6985
  border-color: var(--color-border-secondary);
6968
6986
  color: var(--color-text-secondary);
6969
6987
  }
6970
6988
 
6971
- [data-theme="dark"] .toolbar-actions .btn-icon:hover {
6989
+ [data-theme="dark"] .toolbar-actions .btn-icon:hover,
6990
+ [data-theme="dark"] .toolbar-meta .btn-icon:hover {
6972
6991
  background: var(--color-bg-elevated);
6973
6992
  color: var(--color-text-primary);
6974
6993
  }
6975
6994
 
6995
+ /* --------------------------------------------------------------------------
6996
+ Diff Options Popover (gear icon dropdown for whitespace toggle, etc.)
6997
+ -------------------------------------------------------------------------- */
6998
+ .diff-options-popover {
6999
+ position: fixed;
7000
+ z-index: 1100;
7001
+ background: var(--color-bg-primary);
7002
+ border: 1px solid var(--color-border-primary);
7003
+ border-radius: 8px;
7004
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08), 0 8px 24px rgba(0, 0, 0, 0.12);
7005
+ padding: 4px 0;
7006
+ opacity: 0;
7007
+ transform: translateY(-4px);
7008
+ transition: opacity 0.15s ease, transform 0.15s ease;
7009
+ pointer-events: none;
7010
+ }
7011
+
7012
+ .diff-options-popover.visible {
7013
+ opacity: 1;
7014
+ transform: translateY(0);
7015
+ pointer-events: auto;
7016
+ }
7017
+
7018
+ .diff-options-popover label {
7019
+ display: flex;
7020
+ align-items: center;
7021
+ gap: 8px;
7022
+ padding: 8px 12px;
7023
+ cursor: pointer;
7024
+ font-size: 0.8125rem;
7025
+ color: var(--color-text-primary);
7026
+ white-space: nowrap;
7027
+ user-select: none;
7028
+ transition: background-color 0.1s ease;
7029
+ }
7030
+
7031
+ .diff-options-popover label:hover {
7032
+ background: var(--color-bg-tertiary);
7033
+ }
7034
+
7035
+ .diff-options-popover input[type="checkbox"] {
7036
+ margin: 0;
7037
+ cursor: pointer;
7038
+ }
7039
+
7040
+ /* Active state for the diff-options gear button when a filter is applied */
7041
+ #diff-options-btn.active {
7042
+ color: var(--color-accent-primary);
7043
+ border-color: var(--color-accent-primary);
7044
+ background: var(--color-accent-light);
7045
+ }
7046
+
7047
+ #diff-options-btn.active:hover {
7048
+ background: var(--color-accent-lighter);
7049
+ }
7050
+
7051
+ /* Dark theme */
7052
+ [data-theme="dark"] .diff-options-popover {
7053
+ background: var(--color-bg-secondary);
7054
+ border-color: var(--color-border-secondary);
7055
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.4);
7056
+ }
7057
+
7058
+ [data-theme="dark"] .diff-options-popover label:hover {
7059
+ background: var(--color-bg-tertiary);
7060
+ }
7061
+
7062
+ [data-theme="dark"] #diff-options-btn.active {
7063
+ color: #58a6ff;
7064
+ border-color: #58a6ff;
7065
+ background: rgba(88, 166, 255, 0.1);
7066
+ }
7067
+
7068
+ [data-theme="dark"] #diff-options-btn.active:hover {
7069
+ background: rgba(88, 166, 255, 0.15);
7070
+ }
7071
+
6976
7072
  .ai-panel-header {
6977
7073
  display: flex;
6978
7074
  align-items: center;
@@ -0,0 +1,207 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * DiffOptionsDropdown - Gear-icon popover for diff display options.
4
+ *
5
+ * Anchors a small dropdown below the gear button (#diff-options-btn) with
6
+ * checkbox toggles that control diff rendering. Currently supports a single
7
+ * option: "Hide whitespace changes".
8
+ *
9
+ * Follows the same popover pattern used by PanelGroup._showPopover() /
10
+ * _hidePopover() (fixed positioning via getBoundingClientRect, click-outside
11
+ * and Escape to dismiss, opacity+transform animation).
12
+ *
13
+ * Usage:
14
+ * const dropdown = new DiffOptionsDropdown(
15
+ * document.getElementById('diff-options-btn'),
16
+ * { onToggleWhitespace: (hidden) => { … } }
17
+ * );
18
+ */
19
+
20
+ const STORAGE_KEY = 'pair-review-hide-whitespace';
21
+
22
+ class DiffOptionsDropdown {
23
+ /**
24
+ * @param {HTMLElement} buttonElement - The gear icon button already in the DOM
25
+ * @param {Object} callbacks
26
+ * @param {function(boolean):void} callbacks.onToggleWhitespace
27
+ */
28
+ constructor(buttonElement, { onToggleWhitespace }) {
29
+ this._btn = buttonElement;
30
+ this._onToggleWhitespace = onToggleWhitespace;
31
+
32
+ this._popoverEl = null;
33
+ this._checkbox = null;
34
+ this._visible = false;
35
+ this._outsideClickHandler = null;
36
+ this._escapeHandler = null;
37
+
38
+ // Read persisted state
39
+ this._hideWhitespace = localStorage.getItem(STORAGE_KEY) === 'true';
40
+
41
+ this._renderPopover();
42
+ this._syncButtonActive();
43
+
44
+ // Toggle popover on button click
45
+ this._btn.addEventListener('click', (e) => {
46
+ e.stopPropagation();
47
+ if (this._visible) {
48
+ this._hide();
49
+ } else {
50
+ this._show();
51
+ }
52
+ });
53
+
54
+ // Fire initial callback so the consumer can apply the persisted state
55
+ if (this._hideWhitespace) {
56
+ this._onToggleWhitespace(true);
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Public API
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** @returns {boolean} Whether whitespace changes are currently hidden */
65
+ get hideWhitespace() {
66
+ return this._hideWhitespace;
67
+ }
68
+
69
+ /** Programmatically set the whitespace toggle (updates UI + storage). */
70
+ set hideWhitespace(value) {
71
+ const bool = Boolean(value);
72
+ if (bool === this._hideWhitespace) return;
73
+ this._hideWhitespace = bool;
74
+ if (this._checkbox) this._checkbox.checked = bool;
75
+ this._persist();
76
+ this._syncButtonActive();
77
+ this._onToggleWhitespace(bool);
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // DOM construction
82
+ // ---------------------------------------------------------------------------
83
+
84
+ _renderPopover() {
85
+ const popover = document.createElement('div');
86
+ popover.className = 'diff-options-popover';
87
+ // Start hidden (opacity 0, shifted up)
88
+ popover.style.opacity = '0';
89
+ popover.style.transform = 'translateY(-4px)';
90
+ popover.style.pointerEvents = 'none';
91
+ popover.style.position = 'fixed';
92
+ popover.style.zIndex = '1100';
93
+ popover.style.transition = 'opacity 0.15s ease, transform 0.15s ease';
94
+
95
+ // Label wrapping checkbox for a nice click target
96
+ const label = document.createElement('label');
97
+ label.style.display = 'flex';
98
+ label.style.alignItems = 'center';
99
+ label.style.gap = '8px';
100
+ label.style.cursor = 'pointer';
101
+ label.style.fontSize = '0.8125rem';
102
+ label.style.whiteSpace = 'nowrap';
103
+ label.style.padding = '8px 12px';
104
+ label.style.userSelect = 'none';
105
+
106
+ const checkbox = document.createElement('input');
107
+ checkbox.type = 'checkbox';
108
+ checkbox.checked = this._hideWhitespace;
109
+ checkbox.style.margin = '0';
110
+ checkbox.style.cursor = 'pointer';
111
+
112
+ const text = document.createTextNode('Hide whitespace changes');
113
+
114
+ label.appendChild(checkbox);
115
+ label.appendChild(text);
116
+ popover.appendChild(label);
117
+
118
+ document.body.appendChild(popover);
119
+
120
+ this._popoverEl = popover;
121
+ this._checkbox = checkbox;
122
+
123
+ // Respond to checkbox changes
124
+ checkbox.addEventListener('change', () => {
125
+ this._hideWhitespace = checkbox.checked;
126
+ this._persist();
127
+ this._syncButtonActive();
128
+ this._onToggleWhitespace(this._hideWhitespace);
129
+ });
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Show / Hide (mirrors PanelGroup pattern)
134
+ // ---------------------------------------------------------------------------
135
+
136
+ _show() {
137
+ if (!this._popoverEl || !this._btn) return;
138
+
139
+ // Position below the button
140
+ const rect = this._btn.getBoundingClientRect();
141
+ this._popoverEl.style.top = `${rect.bottom + 4}px`;
142
+ this._popoverEl.style.left = `${rect.left + rect.width / 2}px`;
143
+ this._popoverEl.style.transform = 'translateX(-50%) translateY(-4px)';
144
+
145
+ // Make visible
146
+ this._popoverEl.style.opacity = '1';
147
+ this._popoverEl.style.pointerEvents = 'auto';
148
+ this._visible = true;
149
+
150
+ // Animate into final position
151
+ requestAnimationFrame(() => {
152
+ if (this._popoverEl) {
153
+ this._popoverEl.style.transform = 'translateX(-50%) translateY(0)';
154
+ }
155
+ });
156
+
157
+ // Click-outside-to-close
158
+ this._outsideClickHandler = (e) => {
159
+ if (!this._popoverEl.contains(e.target) && !this._btn.contains(e.target)) {
160
+ this._hide();
161
+ }
162
+ };
163
+ document.addEventListener('click', this._outsideClickHandler, true);
164
+
165
+ // Escape to dismiss
166
+ this._escapeHandler = (e) => {
167
+ if (e.key === 'Escape') {
168
+ this._hide();
169
+ }
170
+ };
171
+ document.addEventListener('keydown', this._escapeHandler, true);
172
+ }
173
+
174
+ _hide() {
175
+ if (!this._popoverEl) return;
176
+
177
+ this._popoverEl.style.opacity = '0';
178
+ this._popoverEl.style.transform = 'translateX(-50%) translateY(-4px)';
179
+ this._popoverEl.style.pointerEvents = 'none';
180
+ this._visible = false;
181
+
182
+ if (this._outsideClickHandler) {
183
+ document.removeEventListener('click', this._outsideClickHandler, true);
184
+ this._outsideClickHandler = null;
185
+ }
186
+ if (this._escapeHandler) {
187
+ document.removeEventListener('keydown', this._escapeHandler, true);
188
+ this._escapeHandler = null;
189
+ }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Helpers
194
+ // ---------------------------------------------------------------------------
195
+
196
+ _persist() {
197
+ localStorage.setItem(STORAGE_KEY, String(this._hideWhitespace));
198
+ }
199
+
200
+ /** Add/remove `.active` on the gear button as a visual cue that filtering is on. */
201
+ _syncButtonActive() {
202
+ if (!this._btn) return;
203
+ this._btn.classList.toggle('active', this._hideWhitespace);
204
+ }
205
+ }
206
+
207
+ window.DiffOptionsDropdown = DiffOptionsDropdown;
@@ -132,7 +132,7 @@ class ReviewModal {
132
132
 
133
133
  <div class="modal-footer review-modal-footer">
134
134
  <button class="btn btn-secondary" onclick="reviewModal.handleCloseClick()" id="cancel-review-btn">Cancel</button>
135
- <button class="btn btn-primary" id="submit-review-btn-modal" onclick="reviewModal.submitReview()">
135
+ <button class="btn btn-primary" id="submit-review-btn-modal" onclick="reviewModal.submitReview()" title="Submit review (Cmd/Ctrl+Enter)">
136
136
  Submit review
137
137
  </button>
138
138
  </div>
@@ -157,11 +157,16 @@ class ReviewModal {
157
157
  }
158
158
  ReviewModal._listenersRegistered = true;
159
159
 
160
- // Handle escape key - uses window.reviewModal to get the current instance
160
+ // Handle keyboard shortcuts - uses window.reviewModal to get the current instance
161
161
  document.addEventListener('keydown', (e) => {
162
162
  const instance = window.reviewModal;
163
- if (e.key === 'Escape' && instance?.isVisible && !instance?.isSubmitting) {
163
+ if (!instance?.isVisible) return;
164
+
165
+ if (e.key === 'Escape' && !instance.isSubmitting) {
164
166
  instance.hide();
167
+ } else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !instance.isSubmitting) {
168
+ e.preventDefault();
169
+ instance.submitReview();
165
170
  }
166
171
  });
167
172
 
@@ -463,6 +463,32 @@ class LocalManager {
463
463
  }
464
464
  };
465
465
 
466
+ // Override handleWhitespaceToggle for local mode.
467
+ // The base PRManager implementation calls loadAndDisplayFiles() which
468
+ // uses the PR diff endpoint. In local mode we need to call loadLocalDiff()
469
+ // instead, which uses the local diff endpoint.
470
+ manager.handleWhitespaceToggle = async function(hide) {
471
+ manager.hideWhitespace = hide;
472
+
473
+ // Nothing to reload if we haven't loaded a review yet
474
+ if (!manager.currentPR) return;
475
+
476
+ const scrollY = window.scrollY;
477
+
478
+ // Re-fetch and re-render the diff (loadLocalDiff reads hideWhitespace)
479
+ await self.loadLocalDiff();
480
+
481
+ // Re-anchor comments and suggestions on the fresh DOM
482
+ const includeDismissed = window.aiPanel?.showDismissedComments || false;
483
+ await manager.loadUserComments(includeDismissed);
484
+ await manager.loadAISuggestions(null, manager.selectedRunId);
485
+
486
+ // Restore scroll position after the DOM settles
487
+ requestAnimationFrame(() => {
488
+ window.scrollTo(0, scrollY);
489
+ });
490
+ };
491
+
466
492
  console.log('PRManager patched for local mode');
467
493
  }
468
494
 
@@ -1050,7 +1076,11 @@ class LocalManager {
1050
1076
  const manager = window.prManager;
1051
1077
 
1052
1078
  try {
1053
- const response = await fetch(`/api/local/${this.reviewId}/diff`);
1079
+ let diffUrl = `/api/local/${this.reviewId}/diff`;
1080
+ if (manager.hideWhitespace) {
1081
+ diffUrl += '?w=1';
1082
+ }
1083
+ const response = await fetch(diffUrl);
1054
1084
 
1055
1085
  if (!response.ok) {
1056
1086
  throw new Error('Failed to load local diff');
package/public/js/pr.js CHANGED
@@ -130,6 +130,12 @@ class PRManager {
130
130
  this.selectedRunId = null;
131
131
  // Keyboard shortcuts manager
132
132
  this.keyboardShortcuts = null;
133
+ // Hide whitespace toggle state — must be set before DiffOptionsDropdown
134
+ // is constructed because it fires the callback synchronously on init
135
+ // when localStorage has a persisted `true` value.
136
+ this.hideWhitespace = false;
137
+ // Diff options dropdown (gear icon popover)
138
+ this.diffOptionsDropdown = null;
133
139
  // Unique client ID for self-echo suppression on SSE review events.
134
140
  // Sent as X-Client-Id header on mutation requests; the server echoes
135
141
  // it back in the SSE broadcast so this tab can skip its own events.
@@ -174,6 +180,16 @@ class PRManager {
174
180
  this.initAnalysisConfigModal();
175
181
  this.initKeyboardShortcuts();
176
182
 
183
+ // Initialize diff options dropdown (gear icon for whitespace toggle).
184
+ // Must happen before init() so the persisted hideWhitespace state is
185
+ // applied before the first loadAndDisplayFiles() call.
186
+ const diffOptionsBtn = document.getElementById('diff-options-btn');
187
+ if (diffOptionsBtn && window.DiffOptionsDropdown) {
188
+ this.diffOptionsDropdown = new window.DiffOptionsDropdown(diffOptionsBtn, {
189
+ onToggleWhitespace: (hide) => this.handleWhitespaceToggle(hide),
190
+ });
191
+ }
192
+
177
193
  // In local mode, LocalManager handles init instead
178
194
  if (!window.PAIR_REVIEW_LOCAL_MODE) {
179
195
  this.init();
@@ -576,7 +592,11 @@ class PRManager {
576
592
  */
577
593
  async loadAndDisplayFiles(owner, repo, number) {
578
594
  try {
579
- const response = await fetch(`/api/pr/${owner}/${repo}/${number}/diff`);
595
+ let diffUrl = `/api/pr/${owner}/${repo}/${number}/diff`;
596
+ if (this.hideWhitespace) {
597
+ diffUrl += '?w=1';
598
+ }
599
+ const response = await fetch(diffUrl);
580
600
 
581
601
  if (response.ok) {
582
602
  const data = await response.json();
@@ -642,6 +662,35 @@ class PRManager {
642
662
  }
643
663
  }
644
664
 
665
+ /**
666
+ * Handle the whitespace visibility toggle from DiffOptionsDropdown.
667
+ * Re-fetches the diff (with or without ?w=1), re-renders it, and
668
+ * re-anchors user comments and AI suggestions on the fresh DOM.
669
+ * @param {boolean} hide - Whether to hide whitespace-only changes
670
+ */
671
+ async handleWhitespaceToggle(hide) {
672
+ this.hideWhitespace = hide;
673
+
674
+ // Nothing to reload if we haven't loaded a PR yet
675
+ if (!this.currentPR) return;
676
+
677
+ const { owner, repo, number } = this.currentPR;
678
+ const scrollY = window.scrollY;
679
+
680
+ // Re-fetch and re-render the diff
681
+ await this.loadAndDisplayFiles(owner, repo, number);
682
+
683
+ // Re-anchor comments and suggestions on the fresh DOM
684
+ const includeDismissed = window.aiPanel?.showDismissedComments || false;
685
+ await this.loadUserComments(includeDismissed);
686
+ await this.loadAISuggestions(null, this.selectedRunId);
687
+
688
+ // Restore scroll position after the DOM settles
689
+ requestAnimationFrame(() => {
690
+ window.scrollTo(0, scrollY);
691
+ });
692
+ }
693
+
645
694
  /**
646
695
  * Parse unified diff to extract per-file patches
647
696
  * @param {string} diff - Full unified diff
@@ -2214,8 +2263,17 @@ class PRManager {
2214
2263
  // If a parent suggestion existed, the suggestion card is still collapsed/dismissed in the diff view.
2215
2264
  // Update AIPanel to show the suggestion as 'dismissed' (matching its visual state).
2216
2265
  // User can click "Show" to restore it to active state if they want to re-adopt.
2217
- if (apiResult.dismissedSuggestionId && window.aiPanel?.updateFindingStatus) {
2218
- window.aiPanel.updateFindingStatus(apiResult.dismissedSuggestionId, 'dismissed');
2266
+ if (apiResult.dismissedSuggestionId) {
2267
+ if (window.aiPanel?.updateFindingStatus) {
2268
+ window.aiPanel.updateFindingStatus(apiResult.dismissedSuggestionId, 'dismissed');
2269
+ }
2270
+ // Clear hiddenForAdoption so that restoring the suggestion takes the API code path
2271
+ // instead of the toggle-only shortcut. Without this, restoring a previously-adopted
2272
+ // suggestion would only toggle visibility without updating its status.
2273
+ const suggestionDiv = document.querySelector(`[data-suggestion-id="${apiResult.dismissedSuggestionId}"]`);
2274
+ if (suggestionDiv) {
2275
+ delete suggestionDiv.dataset.hiddenForAdoption;
2276
+ }
2219
2277
  }
2220
2278
 
2221
2279
  // Show success toast
package/public/local.html CHANGED
@@ -338,6 +338,11 @@
338
338
  <span class="toolbar-stat toolbar-stat-additions" id="pr-additions">+0</span>
339
339
  <span class="toolbar-stat toolbar-stat-deletions" id="pr-deletions">-0</span>
340
340
  <span class="toolbar-files" id="pr-files-count">0 files</span>
341
+ <button class="btn btn-sm btn-icon" id="diff-options-btn" title="Diff options">
342
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
343
+ <path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>
344
+ </svg>
345
+ </button>
341
346
  <span class="toolbar-separator"></span>
342
347
  <span class="toolbar-commit" id="pr-commit" title="Commit SHA">
343
348
  <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
@@ -529,6 +534,7 @@
529
534
  <script src="/js/components/AIPanel.js"></script>
530
535
  <script src="/js/components/EmojiPicker.js"></script>
531
536
  <script src="/js/components/KeyboardShortcuts.js"></script>
537
+ <script src="/js/components/DiffOptionsDropdown.js"></script>
532
538
 
533
539
  <!-- PR Modules (must load before pr.js) -->
534
540
  <script src="/js/modules/storage-cleanup.js"></script>
package/public/pr.html CHANGED
@@ -168,6 +168,11 @@
168
168
  <span class="toolbar-stat toolbar-stat-additions" id="pr-additions">+0</span>
169
169
  <span class="toolbar-stat toolbar-stat-deletions" id="pr-deletions">-0</span>
170
170
  <span class="toolbar-files" id="pr-files-count">0 files</span>
171
+ <button class="btn btn-sm btn-icon" id="diff-options-btn" title="Diff options">
172
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
173
+ <path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>
174
+ </svg>
175
+ </button>
171
176
  <span class="toolbar-separator"></span>
172
177
  <span class="toolbar-commit" id="pr-commit" title="Commit SHA">
173
178
  <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
@@ -352,6 +357,7 @@
352
357
  <script src="/js/components/AIPanel.js"></script>
353
358
  <script src="/js/components/EmojiPicker.js"></script>
354
359
  <script src="/js/components/KeyboardShortcuts.js"></script>
360
+ <script src="/js/components/DiffOptionsDropdown.js"></script>
355
361
 
356
362
  <!-- PR Modules (must load before pr.js) -->
357
363
  <script src="/js/modules/storage-cleanup.js"></script>
package/src/database.js CHANGED
@@ -1921,6 +1921,7 @@ class CommentRepository {
1921
1921
  throw new Error('This suggestion has already been processed');
1922
1922
  }
1923
1923
 
1924
+
1924
1925
  // Create user comment preserving metadata from the suggestion
1925
1926
  const result = await run(this.db, `
1926
1927
  INSERT INTO comments (
@@ -3394,14 +3395,15 @@ class ContextFileRepository {
3394
3395
  /**
3395
3396
  * Update the line range of an existing context file record
3396
3397
  * @param {number} id - Context file record ID
3398
+ * @param {number} reviewId - Review ID (ensures update is scoped to the correct review)
3397
3399
  * @param {number} lineStart - New start line number
3398
3400
  * @param {number} lineEnd - New end line number
3399
3401
  * @returns {Promise<boolean>} True if record was updated
3400
3402
  */
3401
- async updateRange(id, lineStart, lineEnd) {
3403
+ async updateRange(id, reviewId, lineStart, lineEnd) {
3402
3404
  const result = await run(this.db, `
3403
- UPDATE context_files SET line_start = ?, line_end = ? WHERE id = ?
3404
- `, [lineStart, lineEnd, id]);
3405
+ UPDATE context_files SET line_start = ?, line_end = ? WHERE id = ? AND review_id = ?
3406
+ `, [lineStart, lineEnd, id, reviewId]);
3405
3407
 
3406
3408
  return result.changes > 0;
3407
3409
  }
@@ -19,6 +19,45 @@ class GitHubApiError extends Error {
19
19
  }
20
20
  }
21
21
 
22
+ /**
23
+ * Detect whether a GraphQL error is a complexity/cost limit error from GitHub.
24
+ * These errors mean the mutation was too large and can be retried with fewer items.
25
+ *
26
+ * @param {Error} error - The error thrown by octokit.graphql
27
+ * @returns {boolean} True if the error is a complexity/cost limit error
28
+ */
29
+ function isComplexityError(error) {
30
+ const patterns = [
31
+ /complexity/i,
32
+ /MAX_NODE_LIMIT/,
33
+ /cost exceeds/i,
34
+ /too large/i,
35
+ /query size exceeds/i,
36
+ ];
37
+
38
+ // Check the top-level error message
39
+ if (error.message) {
40
+ for (const pattern of patterns) {
41
+ if (pattern.test(error.message)) return true;
42
+ }
43
+ }
44
+
45
+ // Check individual GraphQL error messages in the errors array
46
+ if (error.errors && Array.isArray(error.errors)) {
47
+ for (const err of error.errors) {
48
+ if (err.message) {
49
+ for (const pattern of patterns) {
50
+ if (pattern.test(err.message)) return true;
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ return false;
57
+ }
58
+
59
+ const MIN_BATCH_SIZE = 1;
60
+
22
61
  /**
23
62
  * GitHub API client wrapper with error handling and rate limiting
24
63
  */
@@ -347,42 +386,38 @@ class GitHubClient {
347
386
  * Add comments to a pending review in batches
348
387
  * This helper splits comments into batches to avoid GitHub API limits
349
388
  * on large mutations. Each batch is executed sequentially with retry logic.
389
+ * If a batch fails due to GitHub GraphQL complexity/cost limits, the batch
390
+ * size is automatically halved and the failed batch is retried.
350
391
  *
351
392
  * @param {string} prNodeId - GraphQL node ID for the PR (e.g., "PR_kwDOM...")
352
393
  * @param {string} reviewId - GraphQL node ID for the pending review
353
394
  * @param {Array} comments - Array of comments with path, line (optional), side, body, isFileLevel
354
- * @param {number} batchSize - Number of comments per batch (default: 25)
395
+ * @param {number} batchSize - Number of comments per batch (default: 10)
355
396
  * @returns {Promise<Object>} Result with successCount, failed flag, and failedDetails array of error strings
356
397
  */
357
- // Batch size of 25 is empirically chosen to stay well under GitHub's GraphQL
358
- // mutation size limits while still being efficient for large reviews.
359
- async addCommentsInBatches(prNodeId, reviewId, comments, batchSize = 25) {
398
+ async addCommentsInBatches(prNodeId, reviewId, comments, batchSize = 10) {
360
399
  if (comments.length === 0) {
361
400
  return { successCount: 0, failed: false, failedDetails: [] };
362
401
  }
363
402
 
364
- // Split comments into batches
365
- const batches = [];
366
- for (let i = 0; i < comments.length; i += batchSize) {
367
- batches.push(comments.slice(i, i + batchSize));
368
- }
369
-
370
- console.log(`Adding ${comments.length} comments in ${batches.length} batch(es) of up to ${batchSize} comments each`);
371
-
403
+ let currentBatchSize = batchSize;
404
+ let remaining = comments.slice();
372
405
  let totalSuccessful = 0;
373
406
  const failedDetails = [];
407
+ let batchNumber = 0;
374
408
 
375
- for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
376
- const batch = batches[batchIndex];
377
- const batchNumber = batchIndex + 1;
378
- console.log(`Adding comments batch ${batchNumber}/${batches.length} (${batch.length} comments)...`);
409
+ console.log(`Adding ${comments.length} comments in batches of up to ${currentBatchSize}`);
410
+
411
+ while (remaining.length > 0) {
412
+ batchNumber++;
413
+ const batch = remaining.slice(0, currentBatchSize);
414
+ console.log(`Adding comments batch ${batchNumber} (${batch.length} comments, ${remaining.length} remaining)...`);
379
415
 
380
416
  // Build mutation for this batch
381
417
  const commentMutations = batch.map((comment, index) => {
382
418
  const isFileLevel = comment.isFileLevel || !comment.line;
383
419
 
384
420
  if (isFileLevel) {
385
- // File-level comment (for expanded context lines)
386
421
  return `
387
422
  comment${index}: addPullRequestReviewThread(input: {
388
423
  pullRequestId: $prId
@@ -395,7 +430,6 @@ class GitHubClient {
395
430
  }
396
431
  `;
397
432
  } else {
398
- // Line-level comment
399
433
  const side = comment.side || 'RIGHT';
400
434
  const startLineField = comment.start_line ? `startLine: ${comment.start_line}\n ` : '';
401
435
  return `
@@ -424,6 +458,7 @@ class GitHubClient {
424
458
  let batchError = null;
425
459
  let retryAttempt = 0;
426
460
  const maxRetries = 1;
461
+ let reducedBatchSize = false;
427
462
 
428
463
  while (retryAttempt <= maxRetries) {
429
464
  try {
@@ -435,6 +470,22 @@ class GitHubClient {
435
470
  break; // Success, exit retry loop
436
471
  } catch (error) {
437
472
  batchError = error;
473
+
474
+ // Check for complexity/cost limit errors — reduce batch size instead of retrying
475
+ if (isComplexityError(error)) {
476
+ const newSize = Math.max(MIN_BATCH_SIZE, Math.floor(currentBatchSize / 2));
477
+ if (newSize < currentBatchSize) {
478
+ console.warn(
479
+ `Batch ${batchNumber} hit complexity limit (size ${currentBatchSize}), ` +
480
+ `reducing batch size to ${newSize}`
481
+ );
482
+ currentBatchSize = newSize;
483
+ reducedBatchSize = true;
484
+ break; // Exit retry loop — will re-attempt with smaller batch
485
+ }
486
+ // Already at minimum batch size — fall through to normal retry logic
487
+ }
488
+
438
489
  if (retryAttempt < maxRetries) {
439
490
  console.warn(`Batch ${batchNumber} failed, retrying... (${error.message})`);
440
491
  retryAttempt++;
@@ -450,6 +501,12 @@ class GitHubClient {
450
501
  }
451
502
  }
452
503
 
504
+ // If we reduced batch size due to complexity, retry from top of loop
505
+ // with the same remaining comments but a smaller batch
506
+ if (reducedBatchSize) {
507
+ continue;
508
+ }
509
+
453
510
  // Check if batch succeeded
454
511
  if (batchError) {
455
512
  // Build a map of per-comment errors from the GraphQL errors array.
@@ -522,9 +579,12 @@ class GitHubClient {
522
579
  totalSuccessful += batchSuccessful;
523
580
  console.log(`Batch ${batchNumber} complete: ${batchSuccessful} comments added`);
524
581
  }
582
+
583
+ // Advance past the successfully processed batch
584
+ remaining = remaining.slice(batch.length);
525
585
  }
526
586
 
527
- console.log(`All ${batches.length} batches complete: ${totalSuccessful} total comments added`);
587
+ console.log(`All batches complete: ${totalSuccessful} total comments added`);
528
588
  return { successCount: totalSuccessful, failed: false, failedDetails };
529
589
  }
530
590
 
@@ -1124,4 +1184,4 @@ class GitHubClient {
1124
1184
  }
1125
1185
  }
1126
1186
 
1127
- module.exports = { GitHubClient, GitHubApiError };
1187
+ module.exports = { GitHubClient, GitHubApiError, isComplexityError };
@@ -366,8 +366,9 @@ async function getUntrackedFiles(repoPath) {
366
366
  * @param {string} repoPath - Path to the git repository
367
367
  * @returns {Promise<{diff: string, untrackedFiles: Array, stats: Object}>}
368
368
  */
369
- async function generateLocalDiff(repoPath) {
369
+ async function generateLocalDiff(repoPath, options = {}) {
370
370
  let diff = '';
371
+ const wFlag = options.hideWhitespace ? ' -w' : '';
371
372
  const stats = {
372
373
  trackedChanges: 0,
373
374
  untrackedFiles: 0,
@@ -378,7 +379,7 @@ async function generateLocalDiff(repoPath) {
378
379
  try {
379
380
  // Count staged changes for stats (but don't include in diff)
380
381
  // This is informational only - staged files are excluded from review
381
- const stagedDiff = execSync('git diff --cached --no-color --no-ext-diff --unified=25', {
382
+ const stagedDiff = execSync(`git diff --cached --no-color --no-ext-diff --unified=25${wFlag}`, {
382
383
  cwd: repoPath,
383
384
  encoding: 'utf8',
384
385
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -390,7 +391,7 @@ async function generateLocalDiff(repoPath) {
390
391
  }
391
392
 
392
393
  // Get unstaged changes to tracked files (this is what we show in the review)
393
- const unstagedDiff = execSync('git diff --no-color --no-ext-diff --unified=25', {
394
+ const unstagedDiff = execSync(`git diff --no-color --no-ext-diff --unified=25${wFlag}`, {
394
395
  cwd: repoPath,
395
396
  encoding: 'utf8',
396
397
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -427,7 +428,7 @@ async function generateLocalDiff(repoPath) {
427
428
  // git diff --no-index exits with code 1 when files differ, code 0 when identical
428
429
  let fileDiff;
429
430
  try {
430
- fileDiff = execSync(`git diff --no-index --no-color --no-ext-diff -- /dev/null "${filePath}"`, {
431
+ fileDiff = execSync(`git diff --no-index --no-color --no-ext-diff${wFlag} -- /dev/null "${filePath}"`, {
431
432
  cwd: repoPath,
432
433
  encoding: 'utf8',
433
434
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -209,7 +209,7 @@ router.patch('/api/reviews/:reviewId/context-files/:id', validateReviewId, async
209
209
  const db = req.app.get('db');
210
210
  const contextFileRepo = new ContextFileRepository(db);
211
211
 
212
- const updated = await contextFileRepo.updateRange(id, lineStart, lineEnd);
212
+ const updated = await contextFileRepo.updateRange(id, req.reviewId, lineStart, lineEnd);
213
213
 
214
214
  if (!updated) {
215
215
  return res.status(404).json({ error: 'Context file not found' });
@@ -465,8 +465,24 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
465
465
  });
466
466
  }
467
467
 
468
+ // When ?w=1, regenerate the diff with whitespace changes hidden (transient view, not cached)
469
+ const hideWhitespace = req.query.w === '1';
470
+ let diffData;
471
+
472
+ if (hideWhitespace && review.local_path) {
473
+ try {
474
+ const wsResult = await generateLocalDiff(review.local_path, { hideWhitespace: true });
475
+ diffData = { diff: wsResult.diff, stats: wsResult.stats };
476
+ } catch (wsError) {
477
+ logger.warn(`Could not generate whitespace-filtered diff for review #${reviewId}: ${wsError.message}`);
478
+ // Fall through to cached diff below
479
+ }
480
+ }
481
+
468
482
  // Get diff from module-level storage, falling back to database
469
- let diffData = getLocalReviewDiff(reviewId);
483
+ if (!diffData) {
484
+ diffData = getLocalReviewDiff(reviewId);
485
+ }
470
486
 
471
487
  if (!diffData) {
472
488
  // Try loading from database
package/src/routes/pr.js CHANGED
@@ -18,7 +18,7 @@ const { query, queryOne, run, withTransaction, WorktreeRepository, ReviewReposit
18
18
  const { GitWorktreeManager } = require('../git/worktree');
19
19
  const { GitHubClient } = require('../github/client');
20
20
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
21
- const { normalizeRepository } = require('../utils/paths');
21
+ const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
22
22
  const { mergeInstructions } = require('../utils/instructions');
23
23
  const Analyzer = require('../ai/analyzer');
24
24
  const { v4: uuidv4 } = require('uuid');
@@ -652,15 +652,54 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
652
652
  });
653
653
  }
654
654
 
655
- // Add generated flag to changed files based on .gitattributes
656
- let changedFiles = prData.changed_files || [];
657
-
658
- // Look up worktree path to read .gitattributes
655
+ // Look up worktree path (needed for .gitattributes and whitespace-filtered diffs)
659
656
  const db = req.app.get('db');
660
657
  const worktreeRepo = new WorktreeRepository(db);
661
658
  const worktreeRecord = await worktreeRepo.findByPR(prNumber, repository);
662
659
 
663
- if (worktreeRecord && worktreeRecord.path) {
660
+ // When ?w=1, regenerate the diff from the worktree with whitespace changes hidden
661
+ const hideWhitespace = req.query.w === '1';
662
+ let diffContent = prData.diff || '';
663
+ let changedFiles = prData.changed_files || [];
664
+
665
+ if (hideWhitespace && worktreeRecord && worktreeRecord.path) {
666
+ try {
667
+ const worktreePath = worktreeRecord.path;
668
+ await fs.access(worktreePath);
669
+ const git = simpleGit(worktreePath);
670
+ const baseSha = prData.base_sha;
671
+ const headSha = prData.head_sha;
672
+
673
+ if (baseSha && headSha) {
674
+ // Regenerate diff with -w flag to ignore whitespace changes
675
+ diffContent = await git.diff([`${baseSha}...${headSha}`, '--unified=3', '-w']);
676
+
677
+ // Regenerate changed files stats with -w flag
678
+ const diffSummary = await git.diffSummary([`${baseSha}...${headSha}`, '-w']);
679
+ const gitattributes = await getGeneratedFilePatterns(worktreePath);
680
+ changedFiles = diffSummary.files.map(file => {
681
+ const resolvedFile = resolveRenamedFile(file.file);
682
+ const isRenamed = resolvedFile !== file.file;
683
+ const result = {
684
+ file: resolvedFile,
685
+ insertions: file.insertions,
686
+ deletions: file.deletions,
687
+ changes: file.changes,
688
+ generated: gitattributes.isGenerated(resolvedFile)
689
+ };
690
+ if (isRenamed) {
691
+ result.renamed = true;
692
+ result.renamedFrom = resolveRenamedFileOld(file.file);
693
+ }
694
+ return result;
695
+ });
696
+ }
697
+ } catch (wsError) {
698
+ logger.warn(`Could not generate whitespace-filtered diff for PR #${prNumber}: ${wsError.message}`);
699
+ // Fall back to cached diff (diffContent and changedFiles already set from prData)
700
+ }
701
+ } else if (worktreeRecord && worktreeRecord.path) {
702
+ // Add generated flag to changed files based on .gitattributes
664
703
  try {
665
704
  const gitattributes = await getGeneratedFilePatterns(worktreeRecord.path);
666
705
  changedFiles = changedFiles.map(file => ({
@@ -668,23 +707,33 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
668
707
  generated: gitattributes.isGenerated(file.file)
669
708
  }));
670
709
  } catch (error) {
671
- console.warn('Could not load .gitattributes:', error.message);
710
+ logger.warn(`Could not load .gitattributes: ${error.message}`);
672
711
  // Continue without generated flags
673
712
  }
674
713
  }
675
714
 
715
+ // When hideWhitespace is active and diff was regenerated, compute
716
+ // aggregate stats from the regenerated changedFiles instead of using
717
+ // stale cached values from prData.
718
+ const additions = hideWhitespace
719
+ ? changedFiles.reduce((sum, f) => sum + (f.insertions || 0), 0)
720
+ : (prData.additions || 0);
721
+ const deletions = hideWhitespace
722
+ ? changedFiles.reduce((sum, f) => sum + (f.deletions || 0), 0)
723
+ : (prData.deletions || 0);
724
+
676
725
  res.json({
677
- diff: prData.diff || '',
726
+ diff: diffContent,
678
727
  changed_files: changedFiles,
679
728
  stats: {
680
- additions: prData.additions || 0,
681
- deletions: prData.deletions || 0,
729
+ additions,
730
+ deletions,
682
731
  changed_files: changedFiles.length
683
732
  }
684
733
  });
685
734
 
686
735
  } catch (error) {
687
- console.error('Error fetching PR diff:', error);
736
+ logger.error('Error fetching PR diff:', error);
688
737
  res.status(500).json({
689
738
  error: 'Internal server error while fetching diff data'
690
739
  });
@@ -67,7 +67,7 @@ async function ensureContextFileForComment(db, review, { file, line_start, line_
67
67
  newEnd = newStart + MAX_RANGE - 1;
68
68
  }
69
69
 
70
- await contextFileRepo.updateRange(overlapping.id, newStart, newEnd);
70
+ await contextFileRepo.updateRange(overlapping.id, review.id, newStart, newEnd);
71
71
  return { created: false, expanded: true, contextFileId: overlapping.id };
72
72
  }
73
73