@in-the-loop-labs/pair-review 3.3.5 → 3.3.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.3.5",
3
+ "version": "3.3.7",
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": "3.3.5",
3
+ "version": "3.3.7",
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": "3.3.5",
3
+ "version": "3.3.7",
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",
@@ -965,20 +965,9 @@ class AIPanel {
965
965
  expandFileIfCollapsed(file) {
966
966
  if (!file) return false;
967
967
 
968
- // Find the file wrapper - try exact match first, then partial match
969
- let fileWrapper = document.querySelector(`[data-file-name="${file}"]`);
970
-
971
- // Fallback: partial path match
972
- if (!fileWrapper) {
973
- const allWrappers = document.querySelectorAll('.d2h-file-wrapper');
974
- for (const wrapper of allWrappers) {
975
- const wrapperFile = wrapper.dataset.fileName;
976
- if (wrapperFile && (wrapperFile.includes(file) || file.includes(wrapperFile))) {
977
- fileWrapper = wrapper;
978
- break;
979
- }
980
- }
981
- }
968
+ const fileWrapper = window.prManager?.findFileElement
969
+ ? window.prManager.findFileElement(file)
970
+ : window.DiffRenderer?.findFileElement?.(file);
982
971
 
983
972
  if (!fileWrapper) return false;
984
973
 
@@ -194,6 +194,88 @@ class DiffRenderer {
194
194
  return div.innerHTML;
195
195
  }
196
196
 
197
+ /**
198
+ * Normalize a file path for DOM matching.
199
+ *
200
+ * Frontend mirror of `normalizePath()` + `resolveRenamedFile()` from
201
+ * `src/utils/paths.js`. Browser code cannot `require()` the backend module,
202
+ * so the logic is duplicated here. Keep this function in sync with those two
203
+ * when modifying normalization rules, otherwise the frontend and backend
204
+ * will disagree on which paths are equivalent.
205
+ *
206
+ * @param {string} filePath - File path to normalize
207
+ * @returns {string} Normalized file path
208
+ */
209
+ static normalizeFilePath(filePath) {
210
+ if (typeof filePath !== 'string') return '';
211
+
212
+ let normalized = filePath.trim();
213
+ if (!normalized) return '';
214
+
215
+ // Resolve git rename syntax to the new path.
216
+ normalized = normalized.replace(/\{[^}]*\s*=>\s*([^}]*)\}/, '$1');
217
+ normalized = normalized.replace(/\/+/g, '/');
218
+
219
+ // Strip leading './' and '/' segments until the string stops changing.
220
+ // This mirrors normalizePath in src/utils/paths.js for interleaved cases
221
+ // like '/./src/foo.js'.
222
+ let prevLength;
223
+ do {
224
+ prevLength = normalized.length;
225
+
226
+ while (normalized.startsWith('./')) {
227
+ normalized = normalized.slice(2);
228
+ }
229
+
230
+ while (normalized.startsWith('/')) {
231
+ normalized = normalized.slice(1);
232
+ }
233
+ } while (normalized.length !== prevLength);
234
+
235
+ return normalized;
236
+ }
237
+
238
+ /**
239
+ * Check whether two file paths should be treated as the same DOM target.
240
+ * @param {string} left - First file path
241
+ * @param {string} right - Second file path
242
+ * @returns {boolean} True when the paths refer to the same file
243
+ */
244
+ static pathsMatch(left, right) {
245
+ const normalizedLeft = DiffRenderer.normalizeFilePath(left);
246
+ const normalizedRight = DiffRenderer.normalizeFilePath(right);
247
+
248
+ if (!normalizedLeft || !normalizedRight) return false;
249
+
250
+ return normalizedLeft === normalizedRight ||
251
+ normalizedLeft.endsWith(`/${normalizedRight}`) ||
252
+ normalizedRight.endsWith(`/${normalizedLeft}`);
253
+ }
254
+
255
+ /**
256
+ * Try a direct selector lookup for a file path.
257
+ * Falls back silently when CSS.escape is unavailable or the selector is invalid.
258
+ * @param {string} attribute - data attribute to match (without brackets)
259
+ * @param {string} filePath - File path to search for
260
+ * @returns {Element|null} Matching element if found
261
+ */
262
+ static queryFileElement(attribute, filePath) {
263
+ if (!filePath) {
264
+ return null;
265
+ }
266
+
267
+ const css = globalThis.CSS;
268
+ const escapedValue = css && typeof css.escape === 'function'
269
+ ? css.escape(filePath)
270
+ : filePath;
271
+
272
+ try {
273
+ return document.querySelector(`[${attribute}="${escapedValue}"]`);
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
197
279
  /**
198
280
  * Render a single diff line
199
281
  * @param {HTMLElement|DocumentFragment} container - Container to append to
@@ -696,18 +778,29 @@ class DiffRenderer {
696
778
  * @returns {Element|null} The file wrapper element or null if not found
697
779
  */
698
780
  static findFileElement(filePath) {
699
- // Try exact match first
700
- let fileElement = document.querySelector(`[data-file-name="${filePath}"]`);
701
- if (fileElement) return fileElement;
781
+ const normalizedPath = DiffRenderer.normalizeFilePath(filePath);
702
782
 
703
- fileElement = document.querySelector(`[data-file-path="${filePath}"]`);
704
- if (fileElement) return fileElement;
783
+ // Try direct selector lookups first when we can safely escape the value.
784
+ const selectorCandidates = [filePath];
785
+ if (normalizedPath && normalizedPath !== filePath) {
786
+ selectorCandidates.push(normalizedPath);
787
+ }
788
+
789
+ for (const candidate of selectorCandidates) {
790
+ let fileElement = DiffRenderer.queryFileElement('data-file-name', candidate);
791
+ if (fileElement) return fileElement;
792
+
793
+ fileElement = DiffRenderer.queryFileElement('data-file-path', candidate);
794
+ if (fileElement) return fileElement;
795
+ }
705
796
 
706
- // Try partial match for path segments
797
+ // Fall back to normalized iteration so special characters, rename syntax,
798
+ // and formatted variants still resolve to the rendered wrapper.
707
799
  const allFileWrappers = document.querySelectorAll('.d2h-file-wrapper');
708
800
  for (const wrapper of allFileWrappers) {
709
801
  const fileName = wrapper.dataset.fileName;
710
- if (fileName && (fileName === filePath || fileName.endsWith('/' + filePath) || filePath.endsWith('/' + fileName))) {
802
+ const filePathAttr = wrapper.dataset.filePath;
803
+ if (DiffRenderer.pathsMatch(fileName, filePath) || DiffRenderer.pathsMatch(filePathAttr, filePath)) {
711
804
  return wrapper;
712
805
  }
713
806
  }
@@ -1168,17 +1168,22 @@ class FileCommentManager {
1168
1168
  * @param {Array} suggestions - Array of file-level AI suggestions
1169
1169
  */
1170
1170
  loadFileComments(comments, suggestions) {
1171
- // Group by file
1172
- const commentsByFile = new Map();
1173
- const suggestionsByFile = new Map();
1171
+ // Group by rendered file comments zone so path variants still attach to the
1172
+ // correct file on initial load.
1173
+ const commentsByZone = new Map();
1174
+ const suggestionsByZone = new Map();
1174
1175
 
1175
1176
  if (comments) {
1176
1177
  for (const comment of comments) {
1177
1178
  if (comment.is_file_level === 1) {
1178
- if (!commentsByFile.has(comment.file)) {
1179
- commentsByFile.set(comment.file, []);
1179
+ const zone = this.findZoneForFile(comment.file);
1180
+ if (!zone) {
1181
+ continue;
1180
1182
  }
1181
- commentsByFile.get(comment.file).push(comment);
1183
+ if (!commentsByZone.has(zone)) {
1184
+ commentsByZone.set(zone, []);
1185
+ }
1186
+ commentsByZone.get(zone).push(comment);
1182
1187
  }
1183
1188
  }
1184
1189
  }
@@ -1186,10 +1191,14 @@ class FileCommentManager {
1186
1191
  if (suggestions) {
1187
1192
  for (const suggestion of suggestions) {
1188
1193
  if (suggestion.is_file_level === 1) {
1189
- if (!suggestionsByFile.has(suggestion.file)) {
1190
- suggestionsByFile.set(suggestion.file, []);
1194
+ const zone = this.findZoneForFile(suggestion.file);
1195
+ if (!zone) {
1196
+ continue;
1197
+ }
1198
+ if (!suggestionsByZone.has(zone)) {
1199
+ suggestionsByZone.set(zone, []);
1191
1200
  }
1192
- suggestionsByFile.get(suggestion.file).push(suggestion);
1201
+ suggestionsByZone.get(zone).push(suggestion);
1193
1202
  }
1194
1203
  }
1195
1204
  }
@@ -1197,7 +1206,6 @@ class FileCommentManager {
1197
1206
  // Find all file comment zones and populate them
1198
1207
  const zones = document.querySelectorAll('.file-comments-zone');
1199
1208
  for (const zone of zones) {
1200
- const fileName = zone.dataset.fileName;
1201
1209
  const container = zone.querySelector('.file-comments-container');
1202
1210
 
1203
1211
  // Selectively clear existing cards based on what we're about to reload
@@ -1221,8 +1229,8 @@ class FileCommentManager {
1221
1229
  }
1222
1230
  }
1223
1231
 
1224
- const fileComments = commentsByFile.get(fileName) || [];
1225
- const fileSuggestions = suggestionsByFile.get(fileName) || [];
1232
+ const fileComments = commentsByZone.get(zone) || [];
1233
+ const fileSuggestions = suggestionsByZone.get(zone) || [];
1226
1234
 
1227
1235
  // Display AI suggestions first
1228
1236
  for (const suggestion of fileSuggestions) {
@@ -1245,7 +1253,20 @@ class FileCommentManager {
1245
1253
  * @returns {HTMLElement|null} The zone element or null
1246
1254
  */
1247
1255
  findZoneForFile(fileName) {
1248
- return document.querySelector(`.file-comments-zone[data-file-name="${fileName}"]`);
1256
+ const fileWrapper = window.DiffRenderer?.findFileElement
1257
+ ? window.DiffRenderer.findFileElement(fileName)
1258
+ : null;
1259
+
1260
+ if (fileWrapper) {
1261
+ return fileWrapper.querySelector('.file-comments-zone');
1262
+ }
1263
+
1264
+ try {
1265
+ const escaped = globalThis.CSS?.escape ? CSS.escape(fileName) : fileName;
1266
+ return document.querySelector(`.file-comments-zone[data-file-name="${escaped}"]`);
1267
+ } catch {
1268
+ return null;
1269
+ }
1249
1270
  }
1250
1271
 
1251
1272
  /**
@@ -160,6 +160,26 @@ class SuggestionManager {
160
160
  return window.CategoryEmoji?.getEmoji(category) || '\u{1F4AC}';
161
161
  }
162
162
 
163
+ /**
164
+ * Resolve a diff file wrapper for the provided path.
165
+ * @param {string} file - File path
166
+ * @returns {Element|null} Matching file wrapper
167
+ */
168
+ findFileElement(file) {
169
+ if (this.prManager?.findFileElement) {
170
+ return this.prManager.findFileElement(file);
171
+ }
172
+ if (window.DiffRenderer?.findFileElement) {
173
+ return window.DiffRenderer.findFileElement(file);
174
+ }
175
+ try {
176
+ const escaped = globalThis.CSS?.escape ? CSS.escape(file) : file;
177
+ return document.querySelector(`[data-file-name="${escaped}"]`);
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
163
183
  /**
164
184
  * Find suggestions that target lines currently hidden in gaps
165
185
  * @param {Array} suggestions - Array of suggestions
@@ -176,9 +196,7 @@ class SuggestionManager {
176
196
  const side = suggestion.side || 'RIGHT';
177
197
 
178
198
  // Find the file wrapper
179
- const fileElement = window.DiffRenderer ?
180
- window.DiffRenderer.findFileElement(file) :
181
- document.querySelector(`[data-file-name="${file}"]`);
199
+ const fileElement = this.findFileElement(file);
182
200
 
183
201
  if (!fileElement) {
184
202
  // File not in diff at all, not a hidden line issue
@@ -329,9 +347,7 @@ class SuggestionManager {
329
347
  const line = parseInt(lineStr);
330
348
 
331
349
  // Use helper method for file lookup
332
- const fileElement = window.DiffRenderer ?
333
- window.DiffRenderer.findFileElement(file) :
334
- document.querySelector(`[data-file-name="${file}"]`);
350
+ const fileElement = this.findFileElement(file);
335
351
 
336
352
  if (!fileElement) {
337
353
  // This can happen when AI suggests a file path that doesn't exist in the diff
package/public/js/pr.js CHANGED
@@ -2032,7 +2032,7 @@ class PRManager {
2032
2032
  * @param {string} filePath - Path of the file
2033
2033
  */
2034
2034
  toggleFileCollapse(filePath) {
2035
- const wrapper = document.querySelector(`[data-file-name="${filePath}"]`);
2035
+ const wrapper = this.findFileElement(filePath);
2036
2036
  if (!wrapper) return;
2037
2037
 
2038
2038
  const isCollapsed = wrapper.classList.contains('collapsed');
@@ -2058,7 +2058,7 @@ class PRManager {
2058
2058
  * @param {boolean} isViewed - Whether the file is now viewed
2059
2059
  */
2060
2060
  toggleFileViewed(filePath, isViewed) {
2061
- const wrapper = document.querySelector(`[data-file-name="${filePath}"]`);
2061
+ const wrapper = this.findFileElement(filePath);
2062
2062
 
2063
2063
  if (isViewed) {
2064
2064
  this.viewedFiles.add(filePath);
@@ -4359,7 +4359,7 @@ class PRManager {
4359
4359
  }
4360
4360
 
4361
4361
  scrollToFile(filePath) {
4362
- const fileWrapper = document.querySelector(`[data-file-name="${filePath}"]`);
4362
+ const fileWrapper = this.findFileElement(filePath);
4363
4363
  if (fileWrapper) {
4364
4364
  fileWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
4365
4365
  }
@@ -157,7 +157,9 @@ class RepoSettingsPage {
157
157
  const newProvider = this.providers[newProviderId];
158
158
 
159
159
  if (oldProvider && newProvider) {
160
- const currentModel = oldProvider.models.find(m => m.id === this.currentSettings.default_model);
160
+ // Match by id or alias so legacy model IDs still resolve their tier
161
+ // when the user switches providers.
162
+ const currentModel = this.findModelWithAliases(oldProvider, this.currentSettings.default_model);
161
163
  if (currentModel) {
162
164
  const matchingModel = newProvider.models.find(m => m.tier === currentModel.tier);
163
165
  const defaultModel = newProvider.models.find(m => m.default);
@@ -619,6 +621,22 @@ class RepoSettingsPage {
619
621
  this.selectedProvider = providerId;
620
622
  }
621
623
 
624
+ /**
625
+ * Look up a model by ID within a provider, matching both canonical `id` and
626
+ * `aliases`. Historical repo settings may still reference legacy model IDs
627
+ * (e.g. `gpt-5.4` before reasoning-effort variants were introduced); those
628
+ * must still resolve to the canonical model so the UI shows the correct
629
+ * selection instead of silently falling back to the provider default.
630
+ *
631
+ * @param {Object} provider - Provider object with a `models` array
632
+ * @param {string} modelId - Model ID to look up (may be an alias)
633
+ * @returns {Object|undefined} Matching model definition, or undefined if not found
634
+ */
635
+ findModelWithAliases(provider, modelId) {
636
+ if (!provider || !provider.models || !modelId) return undefined;
637
+ return provider.models.find(m => m.id === modelId || m.aliases?.includes(modelId));
638
+ }
639
+
622
640
  /**
623
641
  * Render model select dropdown for the currently selected provider
624
642
  */
@@ -654,9 +672,10 @@ class RepoSettingsPage {
654
672
  return;
655
673
  }
656
674
 
657
- // Find the selected model, fall back to default or first
675
+ // Find the selected model, fall back to default or first. Match aliases
676
+ // so legacy model IDs still render the canonical card.
658
677
  const modelId = this.currentSettings.default_model;
659
- const model = provider.models.find(m => m.id === modelId)
678
+ const model = this.findModelWithAliases(provider, modelId)
660
679
  || provider.models.find(m => m.default)
661
680
  || provider.models[0];
662
681
 
@@ -691,7 +710,9 @@ class RepoSettingsPage {
691
710
  if (!provider) {
692
711
  return { providerName: providerId || 'Unknown', modelName: modelId || 'Unknown' };
693
712
  }
694
- const model = provider.models?.find(m => m.id === modelId);
713
+ // Match aliases so historical council/voice configs that stored a legacy
714
+ // model ID still show the canonical model's display name.
715
+ const model = this.findModelWithAliases(provider, modelId);
695
716
  return {
696
717
  providerName: provider.name,
697
718
  modelName: model ? model.name : (modelId || 'Unknown')
@@ -1166,11 +1187,20 @@ class RepoSettingsPage {
1166
1187
  this.selectProvider(providerId);
1167
1188
  this.renderProviderSelect();
1168
1189
 
1169
- // Validate saved model exists in current provider
1190
+ // Validate saved model exists in current provider. Match aliases so legacy
1191
+ // model IDs (e.g. `gpt-5.4` recorded before reasoning-effort variants) keep
1192
+ // resolving to the canonical model; if matched via alias, canonicalize the
1193
+ // stored ID so the dropdown selects the right option and the next save
1194
+ // writes the canonical ID back.
1170
1195
  const provider = this.providers[this.selectedProvider];
1171
1196
  if (provider) {
1172
- const modelExists = provider.models.some(m => m.id === this.currentSettings.default_model);
1173
- if (!modelExists) {
1197
+ const matchedModel = this.findModelWithAliases(provider, this.currentSettings.default_model);
1198
+ if (matchedModel) {
1199
+ if (matchedModel.id !== this.currentSettings.default_model) {
1200
+ this.currentSettings.default_model = matchedModel.id;
1201
+ this.originalSettings.default_model = matchedModel.id;
1202
+ }
1203
+ } else {
1174
1204
  const fallbackModel = provider.models.find(m => m.default) || provider.models[0];
1175
1205
  if (fallbackModel) {
1176
1206
  this.currentSettings.default_model = fallbackModel.id;
@@ -24,13 +24,24 @@ const CLAUDE_MODELS = [
24
24
  id: 'opus-4.7-xhigh',
25
25
  cli_model: 'claude-opus-4-7',
26
26
  env: { CLAUDE_CODE_EFFORT_LEVEL: 'xhigh' },
27
- name: 'Opus 4.7 xhigh',
27
+ name: 'Opus 4.7 XHigh',
28
28
  tier: 'thorough',
29
29
  tagline: 'Latest Gen',
30
30
  description: 'Opus 4.7 (latest) with extra-high effort',
31
31
  badge: 'Latest',
32
32
  badgeClass: 'badge-power'
33
33
  },
34
+ {
35
+ id: 'opus-4.7-high',
36
+ cli_model: 'claude-opus-4-7',
37
+ env: { CLAUDE_CODE_EFFORT_LEVEL: 'high' },
38
+ name: 'Opus 4.7 High',
39
+ tier: 'thorough',
40
+ tagline: 'Latest Gen',
41
+ description: 'Opus 4.7 (latest) with high effort',
42
+ badge: 'Latest',
43
+ badgeClass: 'badge-power'
44
+ },
34
45
  {
35
46
  id: 'opus',
36
47
  aliases: ['opus-4.6-high'],