@in-the-loop-labs/pair-review 3.4.0 → 3.5.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.
@@ -133,6 +133,20 @@
133
133
  return div.innerHTML;
134
134
  }
135
135
 
136
+ const LOCAL_REVIEW_PATH_URL_ERROR = 'Local reviews require a filesystem path, not a URL. Pass GitHub or Graphite URLs as PR review inputs instead.';
137
+
138
+ function isUrlLikeLocalReviewPath(value) {
139
+ if (!value || typeof value !== 'string') return false;
140
+ const trimmed = value.trim();
141
+ if (!trimmed) return false;
142
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return true;
143
+ if (/^(?:github\.com|app\.graphite\.(?:dev|com))\//i.test(trimmed)) return true;
144
+ // Keep this aligned with src/utils/local-path-input.js: only a leading
145
+ // user@host:path token is treated as an SSH-style remote.
146
+ if (/^[^@/\\\s]+@[^:/\\\s]+:[^\s]+$/.test(trimmed)) return true;
147
+ return false;
148
+ }
149
+
136
150
  /**
137
151
  * Set loading state for a tab's form
138
152
  * @param {string} tab - 'pr' or 'local'
@@ -710,6 +724,12 @@
710
724
  return;
711
725
  }
712
726
 
727
+ if (isUrlLikeLocalReviewPath(pathValue)) {
728
+ showError('local', LOCAL_REVIEW_PATH_URL_ERROR);
729
+ input.focus();
730
+ return;
731
+ }
732
+
713
733
  // Navigate to the setup page which shows step-by-step progress
714
734
  // The /local?path= route serves setup.html which handles the full setup flow
715
735
  let href = '/local?path=' + encodeURIComponent(pathValue);
@@ -717,6 +737,21 @@
717
737
  window.location.href = href;
718
738
  }
719
739
 
740
+ function handleLocalPathInput(event) {
741
+ const input = event && event.target ? event.target : document.getElementById('local-path-input');
742
+ const errorEl = document.getElementById('start-review-error-local');
743
+ if (!input || !errorEl) return;
744
+
745
+ if (isUrlLikeLocalReviewPath(input.value)) {
746
+ showError('local', LOCAL_REVIEW_PATH_URL_ERROR);
747
+ return;
748
+ }
749
+
750
+ if (errorEl.textContent === LOCAL_REVIEW_PATH_URL_ERROR) {
751
+ errorEl.classList.remove('visible', 'info');
752
+ }
753
+ }
754
+
720
755
  // ─── Browse Directory ──────────────────────────────────────────────────────
721
756
 
722
757
  /**
@@ -746,6 +781,9 @@
746
781
 
747
782
  if (!data.cancelled && data.path) {
748
783
  input.value = data.path;
784
+ // Setting .value in JavaScript does not fire an input event, so run the
785
+ // same handler used for typing to clear any stale URL-specific error.
786
+ handleLocalPathInput({ target: input });
749
787
  input.focus();
750
788
  }
751
789
 
@@ -1873,6 +1911,11 @@
1873
1911
  localForm.addEventListener('submit', handleStartLocal);
1874
1912
  }
1875
1913
 
1914
+ const localPathInput = document.getElementById('local-path-input');
1915
+ if (localPathInput) {
1916
+ localPathInput.addEventListener('input', handleLocalPathInput);
1917
+ }
1918
+
1876
1919
  // Set up browse button handler
1877
1920
  const browseBtn = document.getElementById('browse-local-btn');
1878
1921
  if (browseBtn) {
@@ -2,10 +2,11 @@
2
2
  /**
3
3
  * CommentMinimizer - Manages "minimize comments" mode for the diff view.
4
4
  *
5
- * When active, all inline comment rows (.user-comment-row) and AI suggestion
6
- * rows (.ai-suggestion-row) are hidden via CSS class. Small indicator buttons
7
- * are injected on the right edge of each diff line that has comments, showing
8
- * a person icon (user comments) or sparkles icon (AI suggestions).
5
+ * When active, all inline comment rows (.user-comment-row, .ai-suggestion-row,
6
+ * and .external-comment-row) are hidden via CSS class. Small indicator
7
+ * buttons are injected on the right edge of each diff line that has
8
+ * comments, showing a person icon (user comments), sparkles icon (AI
9
+ * suggestions), or chat-bubble icon (external review comments).
9
10
  *
10
11
  * File-level comments (.file-comment-card inside .file-comments-zone) are also
11
12
  * hidden, with an indicator button injected into the file header bar.
@@ -23,6 +24,9 @@ class CommentMinimizer {
23
24
  /** AI comment icon SVG — speech bubble with sparkles (matches CommentManager.AI_ICON_SVG, different size) */
24
25
  static AI_COMMENT_ICON = `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/></svg>`;
25
26
 
27
+ /** External comment icon SVG — plain chat bubble (octicon-comment). Matches the chat-comment glyph used elsewhere for external rows. */
28
+ static EXTERNAL_ICON = `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><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.25Z"/></svg>`;
29
+
26
30
  constructor() {
27
31
  this._active = false;
28
32
  // Track which diff lines have been expanded by the user (Set of diff row elements)
@@ -70,17 +74,22 @@ class CommentMinimizer {
70
74
 
71
75
  this._removeAllIndicators();
72
76
 
73
- // Find all comment and suggestion rows currently in the DOM
77
+ // Find all comment, suggestion, and external-comment rows currently in the DOM
74
78
  const commentRows = document.querySelectorAll('.user-comment-row');
75
79
  const suggestionRows = document.querySelectorAll('.ai-suggestion-row');
80
+ const externalRows = document.querySelectorAll('.external-comment-row');
76
81
 
77
- // Build a map: diff row element → { hasUser, hasAI, hasAdopted, userCount, aiCount, adoptedCount }
82
+ // Build a map: diff row element → indicator info entry
78
83
  const lineMap = new Map();
84
+ const newEntry = () => ({
85
+ hasUser: false, hasAI: false, hasAdopted: false, hasExternal: false,
86
+ userCount: 0, aiCount: 0, adoptedCount: 0, externalCount: 0
87
+ });
79
88
 
80
89
  for (const row of commentRows) {
81
90
  const diffRow = this._findDiffRowFor(row);
82
91
  if (!diffRow) continue;
83
- const entry = lineMap.get(diffRow) || { hasUser: false, hasAI: false, hasAdopted: false, userCount: 0, aiCount: 0, adoptedCount: 0 };
92
+ const entry = lineMap.get(diffRow) || newEntry();
84
93
  if (row.querySelector('.adopted-comment')) {
85
94
  entry.hasAdopted = true;
86
95
  entry.adoptedCount++;
@@ -94,7 +103,7 @@ class CommentMinimizer {
94
103
  for (const row of suggestionRows) {
95
104
  const diffRow = this._findDiffRowFor(row);
96
105
  if (!diffRow) continue;
97
- const entry = lineMap.get(diffRow) || { hasUser: false, hasAI: false, hasAdopted: false, userCount: 0, aiCount: 0, adoptedCount: 0 };
106
+ const entry = lineMap.get(diffRow) || newEntry();
98
107
  // Count non-adopted suggestion divs only — adopted ones are already
99
108
  // represented by the adopted comment row (avoid double-counting)
100
109
  const allSuggestions = row.querySelectorAll('.ai-suggestion');
@@ -111,6 +120,20 @@ class CommentMinimizer {
111
120
  lineMap.set(diffRow, entry);
112
121
  }
113
122
 
123
+ for (const row of externalRows) {
124
+ const diffRow = this._findDiffRowFor(row);
125
+ if (!diffRow) continue;
126
+ const entry = lineMap.get(diffRow) || newEntry();
127
+ // Count root + replies — each .external-comment is one bubble in the
128
+ // thread card and the reviewer should see the same total here that
129
+ // the thread itself shows.
130
+ const bubbles = row.querySelectorAll('.external-comment');
131
+ const bubbleCount = bubbles.length || 1;
132
+ entry.hasExternal = true;
133
+ entry.externalCount += bubbleCount;
134
+ lineMap.set(diffRow, entry);
135
+ }
136
+
114
137
  // Inject line-level indicators
115
138
  for (const [diffRow, info] of lineMap) {
116
139
  this._injectIndicator(diffRow, info);
@@ -121,8 +144,9 @@ class CommentMinimizer {
121
144
  }
122
145
 
123
146
  /**
124
- * Walk backward from a comment/suggestion row to find its parent diff line.
125
- * Skips other comment rows, suggestion rows, and context-expand rows.
147
+ * Walk backward from a comment/suggestion/external row to find its parent
148
+ * diff line. Skips other comment/suggestion/external rows and
149
+ * context-expand rows.
126
150
  * @param {HTMLElement} row
127
151
  * @returns {HTMLElement|null}
128
152
  */
@@ -132,6 +156,7 @@ class CommentMinimizer {
132
156
  if (
133
157
  !prev.classList.contains('user-comment-row') &&
134
158
  !prev.classList.contains('ai-suggestion-row') &&
159
+ !prev.classList.contains('external-comment-row') &&
135
160
  !prev.classList.contains('comment-form-row') &&
136
161
  !prev.classList.contains('context-expand-row')
137
162
  ) {
@@ -158,10 +183,11 @@ class CommentMinimizer {
158
183
  btn.className = 'comment-indicator';
159
184
  btn.type = 'button';
160
185
 
161
- // Build icon content — three types:
162
- // person (purple) = user-originated comments
186
+ // Build icon content — four types:
187
+ // person (purple) = user-originated comments
163
188
  // ai-comment (purple) = adopted AI suggestions
164
- // sparkles (amber) = AI suggestions
189
+ // sparkles (amber) = AI suggestions
190
+ // chat bubble (blue) = external review comments (e.g. GitHub)
165
191
  const icons = [];
166
192
  if (info.hasUser) {
167
193
  icons.push(`<span class="indicator-icon indicator-user" title="${info.userCount} comment${info.userCount !== 1 ? 's' : ''}">${CommentMinimizer.PERSON_ICON}</span>`);
@@ -172,8 +198,11 @@ class CommentMinimizer {
172
198
  if (info.hasAI) {
173
199
  icons.push(`<span class="indicator-icon indicator-ai" title="${info.aiCount} suggestion${info.aiCount !== 1 ? 's' : ''}">${CommentMinimizer.SPARKLES_ICON}</span>`);
174
200
  }
201
+ if (info.hasExternal) {
202
+ icons.push(`<span class="indicator-icon indicator-external" title="${info.externalCount} external comment${info.externalCount !== 1 ? 's' : ''}">${CommentMinimizer.EXTERNAL_ICON}</span>`);
203
+ }
175
204
 
176
- const total = info.userCount + info.adoptedCount + info.aiCount;
205
+ const total = info.userCount + info.adoptedCount + info.aiCount + info.externalCount;
177
206
  const countBadge = total > 1 ? `<span class="indicator-count">${total}</span>` : '';
178
207
 
179
208
  btn.innerHTML = icons.join('') + countBadge;
@@ -182,11 +211,19 @@ class CommentMinimizer {
182
211
  if (info.userCount) totalLabel.push(`${info.userCount} comment${info.userCount !== 1 ? 's' : ''}`);
183
212
  if (info.adoptedCount) totalLabel.push(`${info.adoptedCount} adopted comment${info.adoptedCount !== 1 ? 's' : ''}`);
184
213
  if (info.aiCount) totalLabel.push(`${info.aiCount} suggestion${info.aiCount !== 1 ? 's' : ''}`);
214
+ if (info.externalCount) totalLabel.push(`${info.externalCount} external comment${info.externalCount !== 1 ? 's' : ''}`);
185
215
  btn.title = totalLabel.join(', ');
186
216
 
187
- // Check if this line was previously expanded
217
+ // Check if this line was previously expanded. Re-applying
218
+ // `.comment-expanded` to the (possibly rebuilt) comment rows is
219
+ // important: when an external-comment refresh tears down and rebuilds
220
+ // `.external-comment-row` elements, the new rows have no expanded
221
+ // class even though the indicator button is still marked expanded.
222
+ // Without this step the indicator says "open" but the rows stay
223
+ // hidden via the `.comments-minimized` display: none rule.
188
224
  if (this._expandedLines.has(diffRow)) {
189
225
  btn.classList.add('expanded');
226
+ this._getCommentRowsFor(diffRow).forEach(row => row.classList.add('comment-expanded'));
190
227
  }
191
228
 
192
229
  // Click handler: toggle this line's comments
@@ -234,7 +271,8 @@ class CommentMinimizer {
234
271
  while (next) {
235
272
  if (
236
273
  next.classList.contains('user-comment-row') ||
237
- next.classList.contains('ai-suggestion-row')
274
+ next.classList.contains('ai-suggestion-row') ||
275
+ next.classList.contains('external-comment-row')
238
276
  ) {
239
277
  rows.push(next);
240
278
  } else if (
@@ -261,8 +299,12 @@ class CommentMinimizer {
261
299
  * @returns {HTMLElement|null} The parent diff row, or null
262
300
  */
263
301
  findDiffRowFor(element) {
264
- const commentRow = element.closest('.user-comment-row, .ai-suggestion-row') || element;
265
- if (!commentRow.classList.contains('user-comment-row') && !commentRow.classList.contains('ai-suggestion-row')) {
302
+ const commentRow = element.closest('.user-comment-row, .ai-suggestion-row, .external-comment-row') || element;
303
+ if (
304
+ !commentRow.classList.contains('user-comment-row') &&
305
+ !commentRow.classList.contains('ai-suggestion-row') &&
306
+ !commentRow.classList.contains('external-comment-row')
307
+ ) {
266
308
  return null;
267
309
  }
268
310
  return this._findDiffRowFor(commentRow);
@@ -293,8 +335,12 @@ class CommentMinimizer {
293
335
  }
294
336
 
295
337
  // Line-level: find the containing comment/suggestion row
296
- const commentRow = element.closest('.user-comment-row, .ai-suggestion-row') || element;
297
- if (!commentRow.classList.contains('user-comment-row') && !commentRow.classList.contains('ai-suggestion-row')) {
338
+ const commentRow = element.closest('.user-comment-row, .ai-suggestion-row, .external-comment-row') || element;
339
+ if (
340
+ !commentRow.classList.contains('user-comment-row') &&
341
+ !commentRow.classList.contains('ai-suggestion-row') &&
342
+ !commentRow.classList.contains('external-comment-row')
343
+ ) {
298
344
  return;
299
345
  }
300
346