@in-the-loop-labs/pair-review 2.6.2 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/bin/git-diff-lines +1 -1
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
  6. package/public/css/pr.css +201 -0
  7. package/public/index.html +168 -3
  8. package/public/js/components/AIPanel.js +16 -2
  9. package/public/js/components/ChatPanel.js +41 -6
  10. package/public/js/components/ConfirmDialog.js +21 -2
  11. package/public/js/components/CouncilProgressModal.js +13 -0
  12. package/public/js/components/DiffOptionsDropdown.js +410 -23
  13. package/public/js/components/SuggestionNavigator.js +12 -5
  14. package/public/js/components/TabTitle.js +96 -0
  15. package/public/js/components/Toast.js +6 -0
  16. package/public/js/index.js +648 -43
  17. package/public/js/local.js +569 -76
  18. package/public/js/modules/analysis-history.js +3 -2
  19. package/public/js/modules/comment-manager.js +5 -0
  20. package/public/js/modules/comment-minimizer.js +304 -0
  21. package/public/js/pr.js +82 -6
  22. package/public/local.html +14 -0
  23. package/public/pr.html +3 -0
  24. package/src/ai/analyzer.js +22 -16
  25. package/src/ai/cursor-agent-provider.js +21 -12
  26. package/src/chat/prompt-builder.js +3 -3
  27. package/src/config.js +2 -0
  28. package/src/database.js +590 -39
  29. package/src/git/base-branch.js +173 -0
  30. package/src/git/sha-abbrev.js +35 -0
  31. package/src/git/worktree.js +3 -2
  32. package/src/github/client.js +32 -1
  33. package/src/hooks/hook-runner.js +100 -0
  34. package/src/hooks/payloads.js +212 -0
  35. package/src/local-review.js +468 -129
  36. package/src/local-scope.js +58 -0
  37. package/src/main.js +57 -6
  38. package/src/routes/analyses.js +73 -10
  39. package/src/routes/chat.js +33 -0
  40. package/src/routes/config.js +1 -0
  41. package/src/routes/github-collections.js +2 -2
  42. package/src/routes/local.js +734 -68
  43. package/src/routes/mcp.js +20 -10
  44. package/src/routes/pr.js +92 -14
  45. package/src/routes/setup.js +1 -0
  46. package/src/routes/worktrees.js +212 -148
  47. package/src/server.js +30 -0
  48. package/src/setup/local-setup.js +46 -5
  49. package/src/setup/pr-setup.js +28 -5
  50. package/src/utils/diff-file-list.js +1 -1
@@ -133,10 +133,20 @@ class ConfirmDialog {
133
133
  messageElement.textContent = options.message || 'Are you sure?';
134
134
  }
135
135
 
136
+ // Helper: set button label + optional description subtitle
137
+ const setBtnContent = (btn, label, description) => {
138
+ if (!btn) return;
139
+ if (description) {
140
+ btn.innerHTML = `<span class="btn-label">${label}</span><span class="btn-desc">${description}</span>`;
141
+ } else {
142
+ btn.textContent = label;
143
+ }
144
+ };
145
+
136
146
  // Set confirm button text and style
137
147
  const confirmBtn = this.modal.querySelector('#confirm-dialog-btn');
138
148
  if (confirmBtn) {
139
- confirmBtn.textContent = options.confirmText || 'Confirm';
149
+ setBtnContent(confirmBtn, options.confirmText || 'Confirm', options.confirmDesc);
140
150
  // Remove previous style classes and add new one
141
151
  confirmBtn.classList.remove('btn-primary', 'btn-secondary', 'btn-danger', 'btn-warning');
142
152
  const confirmClass = options.confirmClass || 'btn-danger';
@@ -145,19 +155,28 @@ class ConfirmDialog {
145
155
 
146
156
  // Set secondary button (optional 3rd button)
147
157
  const secondaryBtn = this.modal.querySelector('#confirm-dialog-secondary-btn');
158
+ const container = this.modal.querySelector('.confirm-dialog-container');
148
159
  if (secondaryBtn) {
149
160
  if (options.secondaryText) {
150
- secondaryBtn.textContent = options.secondaryText;
161
+ setBtnContent(secondaryBtn, options.secondaryText, options.secondaryDesc);
151
162
  secondaryBtn.style.display = '';
152
163
  // Remove previous style classes and add new one
153
164
  secondaryBtn.classList.remove('btn-primary', 'btn-secondary', 'btn-danger', 'btn-warning');
154
165
  const secondaryClass = options.secondaryClass || 'btn-secondary';
155
166
  secondaryBtn.classList.add(secondaryClass);
167
+ if (container) container.classList.add('has-secondary');
156
168
  } else {
157
169
  secondaryBtn.style.display = 'none';
170
+ if (container) container.classList.remove('has-secondary');
158
171
  }
159
172
  }
160
173
 
174
+ // Set cancel button text (optional)
175
+ const cancelBtn = this.modal.querySelector('.modal-footer [data-action="cancel"]');
176
+ if (cancelBtn) {
177
+ setBtnContent(cancelBtn, options.cancelText || 'Cancel', options.cancelDesc);
178
+ }
179
+
161
180
  // Store callbacks with promise resolution
162
181
  this.onConfirm = () => {
163
182
  if (options.onConfirm) {
@@ -806,6 +806,11 @@ class CouncilProgressModal {
806
806
  window.prManager.setButtonComplete();
807
807
  }
808
808
 
809
+ // Flash tab title
810
+ if (window.tabTitle) {
811
+ window.tabTitle.flashComplete();
812
+ }
813
+
809
814
  // Reload suggestions
810
815
  const manager = window.prManager || window.localManager;
811
816
  if (manager && typeof manager.loadAISuggestions === 'function') {
@@ -884,6 +889,11 @@ class CouncilProgressModal {
884
889
  if (window.prManager) {
885
890
  window.prManager.resetButton();
886
891
  }
892
+
893
+ // Flash tab title
894
+ if (window.tabTitle) {
895
+ window.tabTitle.flashFailed();
896
+ }
887
897
  }
888
898
 
889
899
  _handleCancellation(_status) {
@@ -924,6 +934,9 @@ class CouncilProgressModal {
924
934
  if (window.prManager) {
925
935
  window.prManager.resetButton();
926
936
  }
937
+
938
+ // No tab-title flash for cancellation — the user initiated it, so no notification needed.
939
+
927
940
  if (window.aiPanel?.setAnalysisState) {
928
941
  window.aiPanel.setAnalysisState('unknown');
929
942
  }
@@ -3,58 +3,95 @@
3
3
  * DiffOptionsDropdown - Gear-icon popover for diff display options.
4
4
  *
5
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".
6
+ * checkbox toggles that control diff rendering. Supports:
7
+ * - "Hide whitespace changes"
8
+ * - "Minimize comments" (collapse inline comments to line indicators)
9
+ * - Scope range selector (local mode only)
8
10
  *
9
11
  * Follows the same popover pattern used by PanelGroup._showPopover() /
10
12
  * _hidePopover() (fixed positioning via getBoundingClientRect, click-outside
11
13
  * and Escape to dismiss, opacity+transform animation).
12
14
  *
15
+ * IMPORTANT: This dropdown is constructed in BOTH pr.js and local.js.
16
+ * LocalManager (local.js) destroys the pr.js instance and recreates it
17
+ * with scope-selector support. Any new callback added here MUST be
18
+ * threaded through both construction sites or it will silently no-op
19
+ * in local mode.
20
+ *
13
21
  * Usage:
14
22
  * const dropdown = new DiffOptionsDropdown(
15
23
  * document.getElementById('diff-options-btn'),
16
- * { onToggleWhitespace: (hidden) => { … } }
24
+ * {
25
+ * onToggleWhitespace: (hidden) => { … },
26
+ * onToggleMinimize: (minimized) => { … },
27
+ * onScopeChange: (start, end) => { … },
28
+ * initialScope: { start: 'unstaged', end: 'untracked' },
29
+ * branchAvailable: false
30
+ * }
17
31
  * );
18
32
  */
19
33
 
20
34
  const STORAGE_KEY = 'pair-review-hide-whitespace';
35
+ const MINIMIZE_STORAGE_KEY = 'pair-review-minimize-comments';
21
36
 
22
37
  class DiffOptionsDropdown {
23
38
  /**
24
39
  * @param {HTMLElement} buttonElement - The gear icon button already in the DOM
25
40
  * @param {Object} callbacks
26
41
  * @param {function(boolean):void} callbacks.onToggleWhitespace
42
+ * @param {function(boolean):void} [callbacks.onToggleMinimize]
43
+ * @param {function(string,string):void} [callbacks.onScopeChange]
44
+ * @param {{start:string,end:string}} [callbacks.initialScope]
45
+ * @param {boolean} [callbacks.branchAvailable]
27
46
  */
28
- constructor(buttonElement, { onToggleWhitespace }) {
47
+ constructor(buttonElement, { onToggleWhitespace, onToggleMinimize, onScopeChange, initialScope, branchAvailable }) {
29
48
  this._btn = buttonElement;
30
49
  this._onToggleWhitespace = onToggleWhitespace;
50
+ this._onToggleMinimize = onToggleMinimize || (() => {});
51
+ this._onScopeChange = onScopeChange || null;
31
52
 
32
53
  this._popoverEl = null;
33
54
  this._checkbox = null;
55
+ this._minimizeCheckbox = null;
34
56
  this._visible = false;
35
57
  this._outsideClickHandler = null;
36
58
  this._escapeHandler = null;
37
59
 
60
+ // Scope state
61
+ const LS = window.LocalScope;
62
+ this._branchAvailable = Boolean(branchAvailable);
63
+ this._scopeStart = (initialScope && initialScope.start) || (LS ? LS.DEFAULT_SCOPE.start : 'unstaged');
64
+ this._scopeEnd = (initialScope && initialScope.end) || (LS ? LS.DEFAULT_SCOPE.end : 'untracked');
65
+ this._scopeStops = [];
66
+ this._scopeTrackEl = null;
67
+ this._scopeDebounceTimer = null;
68
+ this._scopeStatusEl = null;
69
+
38
70
  // Read persisted state
39
71
  this._hideWhitespace = localStorage.getItem(STORAGE_KEY) === 'true';
72
+ this._minimizeComments = localStorage.getItem(MINIMIZE_STORAGE_KEY) === 'true';
40
73
 
41
74
  this._renderPopover();
42
75
  this._syncButtonActive();
43
76
 
44
77
  // Toggle popover on button click
45
- this._btn.addEventListener('click', (e) => {
78
+ this._btnClickHandler = (e) => {
46
79
  e.stopPropagation();
47
80
  if (this._visible) {
48
81
  this._hide();
49
82
  } else {
50
83
  this._show();
51
84
  }
52
- });
85
+ };
86
+ this._btn.addEventListener('click', this._btnClickHandler);
53
87
 
54
- // Fire initial callback so the consumer can apply the persisted state
88
+ // Fire initial callbacks so the consumer can apply persisted state
55
89
  if (this._hideWhitespace) {
56
90
  this._onToggleWhitespace(true);
57
91
  }
92
+ if (this._minimizeComments) {
93
+ this._onToggleMinimize(true);
94
+ }
58
95
  }
59
96
 
60
97
  // ---------------------------------------------------------------------------
@@ -77,6 +114,65 @@ class DiffOptionsDropdown {
77
114
  this._onToggleWhitespace(bool);
78
115
  }
79
116
 
117
+ /** @returns {boolean} Whether comments are currently minimized */
118
+ get minimizeComments() {
119
+ return this._minimizeComments;
120
+ }
121
+
122
+ /** Programmatically set the minimize toggle (updates UI + storage). */
123
+ set minimizeComments(value) {
124
+ const bool = Boolean(value);
125
+ if (bool === this._minimizeComments) return;
126
+ this._minimizeComments = bool;
127
+ if (this._minimizeCheckbox) this._minimizeCheckbox.checked = bool;
128
+ this._persist();
129
+ this._syncButtonActive();
130
+ this._onToggleMinimize(bool);
131
+ }
132
+
133
+ /** Update branch availability (e.g. after base branch is set). */
134
+ set branchAvailable(value) {
135
+ this._branchAvailable = Boolean(value);
136
+ this._updateScopeUI();
137
+ }
138
+
139
+ /** Get current scope as {start, end}. */
140
+ get scope() {
141
+ return { start: this._scopeStart, end: this._scopeEnd };
142
+ }
143
+
144
+ /** Programmatically set scope. */
145
+ set scope(val) {
146
+ if (!val) return;
147
+ const LS = window.LocalScope;
148
+ if (LS && !LS.isValidScope(val.start, val.end)) return;
149
+ this._scopeStart = val.start;
150
+ this._scopeEnd = val.end;
151
+ this._updateScopeUI();
152
+ }
153
+
154
+ /** Clear the scope status indicator (call after scope change completes). */
155
+ clearScopeStatus() {
156
+ if (this._scopeStatusEl) {
157
+ this._scopeStatusEl.style.display = 'none';
158
+ this._scopeStatusEl.textContent = '';
159
+ }
160
+ }
161
+
162
+ /** Remove all DOM elements and event listeners. Safe to call multiple times. */
163
+ destroy() {
164
+ this._hide();
165
+ clearTimeout(this._scopeDebounceTimer);
166
+ if (this._popoverEl) {
167
+ this._popoverEl.remove();
168
+ this._popoverEl = null;
169
+ }
170
+ if (this._btn && this._btnClickHandler) {
171
+ this._btn.removeEventListener('click', this._btnClickHandler);
172
+ this._btnClickHandler = null;
173
+ }
174
+ }
175
+
80
176
  // ---------------------------------------------------------------------------
81
177
  // DOM construction
82
178
  // ---------------------------------------------------------------------------
@@ -92,7 +188,57 @@ class DiffOptionsDropdown {
92
188
  popover.style.zIndex = '1100';
93
189
  popover.style.transition = 'opacity 0.15s ease, transform 0.15s ease';
94
190
 
95
- // Label wrapping checkbox for a nice click target
191
+ // Scope selector first only in local mode
192
+ if (window.PAIR_REVIEW_LOCAL_MODE && window.LocalScope) {
193
+ this._renderScopeSelector(popover);
194
+
195
+ // Divider between scope selector and whitespace checkbox
196
+ const divider = document.createElement('div');
197
+ divider.style.height = '1px';
198
+ divider.style.background = 'var(--color-border-primary, #d0d7de)';
199
+ divider.style.margin = '0 20px';
200
+ popover.appendChild(divider);
201
+ }
202
+
203
+ // --- Whitespace checkbox ---
204
+ const wsLabel = this._createCheckboxLabel('Hide whitespace changes', this._hideWhitespace);
205
+ const wsCheckbox = wsLabel.querySelector('input');
206
+ popover.appendChild(wsLabel);
207
+
208
+ // --- Minimize comments checkbox ---
209
+ const minLabel = this._createCheckboxLabel('Minimize comments', this._minimizeComments);
210
+ const minCheckbox = minLabel.querySelector('input');
211
+ popover.appendChild(minLabel);
212
+
213
+ document.body.appendChild(popover);
214
+
215
+ this._popoverEl = popover;
216
+ this._checkbox = wsCheckbox;
217
+ this._minimizeCheckbox = minCheckbox;
218
+
219
+ // Respond to checkbox changes
220
+ wsCheckbox.addEventListener('change', () => {
221
+ this._hideWhitespace = wsCheckbox.checked;
222
+ this._persist();
223
+ this._syncButtonActive();
224
+ this._onToggleWhitespace(this._hideWhitespace);
225
+ });
226
+
227
+ minCheckbox.addEventListener('change', () => {
228
+ this._minimizeComments = minCheckbox.checked;
229
+ this._persist();
230
+ this._syncButtonActive();
231
+ this._onToggleMinimize(this._minimizeComments);
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Create a label element wrapping a checkbox.
237
+ * @param {string} text - Label text
238
+ * @param {boolean} checked - Initial checked state
239
+ * @returns {HTMLLabelElement}
240
+ */
241
+ _createCheckboxLabel(text, checked) {
96
242
  const label = document.createElement('label');
97
243
  label.style.display = 'flex';
98
244
  label.style.alignItems = 'center';
@@ -105,28 +251,268 @@ class DiffOptionsDropdown {
105
251
 
106
252
  const checkbox = document.createElement('input');
107
253
  checkbox.type = 'checkbox';
108
- checkbox.checked = this._hideWhitespace;
254
+ checkbox.checked = checked;
109
255
  checkbox.style.margin = '0';
110
256
  checkbox.style.cursor = 'pointer';
111
257
 
112
- const text = document.createTextNode('Hide whitespace changes');
113
-
114
258
  label.appendChild(checkbox);
115
- label.appendChild(text);
116
- popover.appendChild(label);
259
+ label.appendChild(document.createTextNode(text));
260
+ return label;
261
+ }
117
262
 
118
- document.body.appendChild(popover);
263
+ _renderScopeSelector(popover) {
264
+ const LS = window.LocalScope;
265
+
266
+ // Section container — generous horizontal padding so dots/labels breathe
267
+ const section = document.createElement('div');
268
+ section.style.padding = '8px 20px 12px';
269
+ section.className = 'scope-selector-section';
270
+
271
+ // Title row with status indicator
272
+ const titleRow = document.createElement('div');
273
+ titleRow.style.display = 'flex';
274
+ titleRow.style.alignItems = 'center';
275
+ titleRow.style.justifyContent = 'space-between';
276
+ titleRow.style.marginBottom = '10px';
277
+
278
+ const title = document.createElement('div');
279
+ title.textContent = 'Diff scope';
280
+ title.style.fontSize = '0.8125rem';
281
+ title.style.fontWeight = '600';
282
+ title.style.color = 'var(--color-text-primary, #24292f)';
283
+
284
+ const statusEl = document.createElement('div');
285
+ statusEl.style.fontSize = '11px';
286
+ statusEl.style.color = 'var(--color-text-secondary, #656d76)';
287
+ statusEl.style.display = 'none';
288
+ this._scopeStatusEl = statusEl;
289
+
290
+ titleRow.appendChild(title);
291
+ titleRow.appendChild(statusEl);
292
+ section.appendChild(titleRow);
293
+
294
+ // Track container
295
+ const trackContainer = document.createElement('div');
296
+ trackContainer.style.position = 'relative';
297
+ trackContainer.style.padding = '0';
298
+ this._numStops = LS.STOPS.length;
299
+
300
+ // Track background line — spans between centers of first and last columns.
301
+ // With N equal flex columns each 1/N wide, first center is at 1/(2N) and
302
+ // last center is at 1 - 1/(2N), so the line inset is 100%/(2N) on each side.
303
+ const trackInset = `calc(100% / ${this._numStops * 2})`;
304
+ const trackLine = document.createElement('div');
305
+ trackLine.style.position = 'absolute';
306
+ trackLine.style.top = '6px';
307
+ trackLine.style.left = trackInset;
308
+ trackLine.style.right = trackInset;
309
+ trackLine.style.height = '2px';
310
+ trackLine.style.background = 'var(--color-border-primary, #d0d7de)';
311
+ trackLine.style.borderRadius = '1px';
312
+ trackContainer.appendChild(trackLine);
313
+
314
+ // Highlighted range bar — positioned relative to the track line span
315
+ const rangeBar = document.createElement('div');
316
+ rangeBar.style.position = 'absolute';
317
+ rangeBar.style.top = '6px';
318
+ rangeBar.style.height = '2px';
319
+ rangeBar.style.background = 'var(--ai-primary, #8b5cf6)';
320
+ rangeBar.style.borderRadius = '1px';
321
+ rangeBar.style.transition = 'left 0.15s ease, width 0.15s ease';
322
+ trackContainer.appendChild(rangeBar);
323
+ this._rangeBarEl = rangeBar;
324
+
325
+ // Stops row — equal-width columns so dots are evenly spaced
326
+ const stopsRow = document.createElement('div');
327
+ stopsRow.style.display = 'flex';
328
+ stopsRow.style.position = 'relative';
329
+
330
+ this._scopeStops = [];
331
+
332
+ LS.STOPS.forEach((stop, i) => {
333
+ const stopEl = document.createElement('div');
334
+ stopEl.style.display = 'flex';
335
+ stopEl.style.flexDirection = 'column';
336
+ stopEl.style.alignItems = 'center';
337
+ stopEl.style.cursor = 'pointer';
338
+ stopEl.style.userSelect = 'none';
339
+ stopEl.style.flex = '1';
340
+ stopEl.dataset.stop = stop;
341
+
342
+ const dot = document.createElement('div');
343
+ dot.style.width = '14px';
344
+ dot.style.height = '14px';
345
+ dot.style.borderRadius = '50%';
346
+ dot.style.border = '2px solid var(--color-border-primary, #d0d7de)';
347
+ dot.style.boxSizing = 'border-box';
348
+ dot.style.background = 'var(--color-bg-primary, #ffffff)';
349
+ dot.style.transition = 'background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease';
350
+ dot.style.position = 'relative';
351
+ dot.style.zIndex = '1';
352
+ dot.style.marginBottom = '6px';
353
+
354
+ const labelEl = document.createElement('div');
355
+ labelEl.textContent = stop.charAt(0).toUpperCase() + stop.slice(1);
356
+ labelEl.style.fontSize = '11px';
357
+ labelEl.style.lineHeight = '1.2';
358
+ labelEl.style.color = 'var(--color-text-secondary, #656d76)';
359
+ labelEl.style.whiteSpace = 'nowrap';
360
+ labelEl.style.transition = 'color 0.15s ease';
361
+
362
+ stopEl.appendChild(dot);
363
+ stopEl.appendChild(labelEl);
364
+
365
+ stopEl.addEventListener('click', (e) => {
366
+ e.stopPropagation();
367
+ this._handleStopClick(stop, e);
368
+ });
369
+
370
+ stopsRow.appendChild(stopEl);
371
+ this._scopeStops.push({ stop, dotEl: dot, labelEl, containerEl: stopEl });
372
+ });
119
373
 
120
- this._popoverEl = popover;
121
- this._checkbox = checkbox;
374
+ trackContainer.appendChild(stopsRow);
375
+ section.appendChild(trackContainer);
122
376
 
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);
377
+ this._scopeTrackEl = trackContainer;
378
+ popover.appendChild(section);
379
+
380
+ // Initial UI sync
381
+ this._updateScopeUI();
382
+ }
383
+
384
+ _handleStopClick(clickedStop, event) {
385
+ const LS = window.LocalScope;
386
+ if (!LS) return;
387
+
388
+ // Branch disabled? Ignore.
389
+ if (clickedStop === 'branch' && !this._branchAvailable) return;
390
+
391
+ const stops = LS.STOPS;
392
+ const ci = stops.indexOf(clickedStop);
393
+ const si = stops.indexOf(this._scopeStart);
394
+ const ei = stops.indexOf(this._scopeEnd);
395
+
396
+ let newStart = this._scopeStart;
397
+ let newEnd = this._scopeEnd;
398
+
399
+ // Alt/Option-click: solo-select this single stop
400
+ if (event && event.altKey) {
401
+ newStart = clickedStop;
402
+ newEnd = clickedStop;
403
+ } else {
404
+ // Checkbox-like toggle with contiguity constraint
405
+ const included = ci >= si && ci <= ei;
406
+
407
+ if (included) {
408
+ // Toggling OFF — only allowed at boundaries, and range must have >1 stop
409
+ if (si === ei) return;
410
+ if (ci === si) {
411
+ newStart = stops[si + 1];
412
+ } else if (ci === ei) {
413
+ newEnd = stops[ei - 1];
414
+ } else {
415
+ return; // Interior — can't break contiguity
416
+ }
417
+ } else {
418
+ // Toggling ON — only allowed if adjacent to current range
419
+ if (ci === si - 1) {
420
+ newStart = clickedStop;
421
+ } else if (ci === ei + 1) {
422
+ newEnd = clickedStop;
423
+ } else {
424
+ return; // Not adjacent — can't break contiguity
425
+ }
426
+ }
427
+ }
428
+
429
+ // If branch not available and start would be branch, clamp
430
+ if (!this._branchAvailable && newStart === 'branch') {
431
+ newStart = 'staged';
432
+ }
433
+
434
+ if (!LS.isValidScope(newStart, newEnd)) return;
435
+ if (newStart === this._scopeStart && newEnd === this._scopeEnd) return;
436
+
437
+ this._scopeStart = newStart;
438
+ this._scopeEnd = newEnd;
439
+ this._updateScopeUI();
440
+
441
+ // Show pending status and debounce the backend call
442
+ this._setScopeStatus('Updating\u2026');
443
+
444
+ clearTimeout(this._scopeDebounceTimer);
445
+ this._scopeDebounceTimer = setTimeout(() => {
446
+ this._setScopeStatus('Loading diff\u2026');
447
+ if (this._onScopeChange) {
448
+ this._onScopeChange(this._scopeStart, this._scopeEnd);
449
+ }
450
+ }, 600);
451
+ }
452
+
453
+ _setScopeStatus(text) {
454
+ if (!this._scopeStatusEl) return;
455
+ this._scopeStatusEl.textContent = text;
456
+ this._scopeStatusEl.style.display = text ? 'block' : 'none';
457
+ }
458
+
459
+ _updateScopeUI() {
460
+ const LS = window.LocalScope;
461
+ if (!LS || !this._scopeStops.length) return;
462
+
463
+ const stops = LS.STOPS;
464
+ const si = stops.indexOf(this._scopeStart);
465
+ const ei = stops.indexOf(this._scopeEnd);
466
+
467
+ this._scopeStops.forEach(({ stop, dotEl, labelEl, containerEl }, i) => {
468
+ const included = LS.scopeIncludes(this._scopeStart, this._scopeEnd, stop);
469
+ const isBranch = stop === 'branch';
470
+ const disabled = isBranch && !this._branchAvailable;
471
+
472
+ // Determine if clicking this stop would do anything (for cursor hint)
473
+ const isBoundary = included && (i === si || i === ei) && si !== ei;
474
+ const isAdjacent = !included && (i === si - 1 || i === ei + 1);
475
+ const clickable = !disabled && (isBoundary || isAdjacent);
476
+
477
+ if (disabled) {
478
+ // Disabled state
479
+ dotEl.style.background = 'var(--color-bg-tertiary, #f6f8fa)';
480
+ dotEl.style.borderColor = 'var(--color-border-secondary, #e1e4e8)';
481
+ dotEl.style.boxShadow = 'none';
482
+ labelEl.style.color = 'var(--color-text-tertiary, #8b949e)';
483
+ containerEl.style.cursor = 'not-allowed';
484
+ containerEl.style.opacity = '0.5';
485
+ } else if (included) {
486
+ // Included (filled) state
487
+ dotEl.style.background = 'var(--ai-primary, #8b5cf6)';
488
+ dotEl.style.borderColor = 'var(--ai-primary, #8b5cf6)';
489
+ dotEl.style.boxShadow = '0 0 0 2px rgba(139, 92, 246, 0.2)';
490
+ labelEl.style.color = 'var(--color-text-primary, #24292f)';
491
+ labelEl.style.fontWeight = '600';
492
+ containerEl.style.cursor = clickable ? 'pointer' : 'default';
493
+ containerEl.style.opacity = '1';
494
+ } else {
495
+ // Excluded (empty) state
496
+ dotEl.style.background = 'var(--color-bg-primary, #ffffff)';
497
+ dotEl.style.borderColor = 'var(--color-border-primary, #d0d7de)';
498
+ dotEl.style.boxShadow = 'none';
499
+ labelEl.style.color = 'var(--color-text-secondary, #656d76)';
500
+ labelEl.style.fontWeight = 'normal';
501
+ containerEl.style.cursor = clickable ? 'pointer' : 'default';
502
+ containerEl.style.opacity = clickable ? '1' : '0.6';
503
+ }
129
504
  });
505
+
506
+ // Update range bar position.
507
+ // With N equal flex columns, center of column i = (2*i + 1) / (2*N).
508
+ // Range bar spans from center of start column to center of end column.
509
+ if (this._rangeBarEl && this._scopeStops.length >= 2) {
510
+ const N = stops.length;
511
+ const startCenter = (2 * si + 1) / (2 * N);
512
+ const endCenter = (2 * ei + 1) / (2 * N);
513
+ this._rangeBarEl.style.left = `calc(100% * ${startCenter})`;
514
+ this._rangeBarEl.style.width = `calc(100% * ${endCenter - startCenter})`;
515
+ }
130
516
  }
131
517
 
132
518
  // ---------------------------------------------------------------------------
@@ -195,12 +581,13 @@ class DiffOptionsDropdown {
195
581
 
196
582
  _persist() {
197
583
  localStorage.setItem(STORAGE_KEY, String(this._hideWhitespace));
584
+ localStorage.setItem(MINIMIZE_STORAGE_KEY, String(this._minimizeComments));
198
585
  }
199
586
 
200
587
  /** Add/remove `.active` on the gear button as a visual cue that filtering is on. */
201
588
  _syncButtonActive() {
202
589
  if (!this._btn) return;
203
- this._btn.classList.toggle('active', this._hideWhitespace);
590
+ this._btn.classList.toggle('active', this._hideWhitespace || this._minimizeComments);
204
591
  }
205
592
  }
206
593
 
@@ -369,11 +369,18 @@ class SuggestionNavigator {
369
369
  const suggestionEl = document.querySelector(`[data-suggestion-id="${currentSuggestion.id}"]`);
370
370
 
371
371
  if (suggestionEl) {
372
- suggestionEl.scrollIntoView({
373
- behavior: 'smooth',
374
- block: 'center',
375
- inline: 'nearest'
376
- });
372
+ const minimizer = window.prManager?.commentMinimizer;
373
+ if (minimizer?.active) {
374
+ // Comments are minimized — scroll to the parent diff line instead
375
+ const diffRow = minimizer.findDiffRowFor(suggestionEl);
376
+ if (diffRow) {
377
+ diffRow.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
378
+ } else {
379
+ suggestionEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
380
+ }
381
+ } else {
382
+ suggestionEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
383
+ }
377
384
  }
378
385
  }
379
386
  }