@in-the-loop-labs/pair-review 2.3.0 → 2.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "2.3.0",
3
+ "version": "2.3.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.3.0",
3
+ "version": "2.3.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.3.0",
3
+ "version": "2.3.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",
@@ -207,6 +207,45 @@ class AIPanel {
207
207
  }
208
208
  }
209
209
 
210
+ /**
211
+ * Get the localStorage key for the collapsed state (per-review)
212
+ * @returns {string|null} Storage key or null if no PR context
213
+ */
214
+ _getCollapsedStorageKey() {
215
+ if (!this.currentPRKey) return null;
216
+ return `pair-review-panel-collapsed_${this.currentPRKey}`;
217
+ }
218
+
219
+ /**
220
+ * Save the collapsed state to localStorage (per-review)
221
+ */
222
+ _saveCollapsedState() {
223
+ const key = this._getCollapsedStorageKey();
224
+ if (key) {
225
+ localStorage.setItem(key, this.isCollapsed ? 'true' : 'false');
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Restore the collapsed state from localStorage, or collapse for new reviews.
231
+ * If the user had the panel expanded for this review, expand it.
232
+ * Otherwise (new review or previously collapsed), collapse it.
233
+ */
234
+ _restoreOrCollapsePanel() {
235
+ const key = this._getCollapsedStorageKey();
236
+ if (key) {
237
+ const stored = localStorage.getItem(key);
238
+ if (stored === 'false') {
239
+ this.expand();
240
+ } else {
241
+ // 'true' or no saved state (new review) → collapse
242
+ this.collapse();
243
+ }
244
+ } else {
245
+ this.collapse();
246
+ }
247
+ }
248
+
210
249
  /**
211
250
  * Get the localStorage key for the filter state (per-review)
212
251
  * @returns {string|null} Storage key or null if no PR context
@@ -275,6 +314,7 @@ class AIPanel {
275
314
  // Set CSS variable to 0 so width calculations don't reserve space
276
315
  document.documentElement.style.setProperty('--ai-panel-width', '0px');
277
316
  window.panelGroup?._onReviewVisibilityChanged(false);
317
+ this._saveCollapsedState();
278
318
  }
279
319
 
280
320
  expand() {
@@ -285,6 +325,7 @@ class AIPanel {
285
325
  // Restore CSS variable from saved width or default
286
326
  document.documentElement.style.setProperty('--ai-panel-width', `${this.getEffectivePanelWidth()}px`);
287
327
  window.panelGroup?._onReviewVisibilityChanged(true);
328
+ this._saveCollapsedState();
288
329
  }
289
330
 
290
331
  /**
@@ -296,6 +337,7 @@ class AIPanel {
296
337
  */
297
338
  setPR(owner, repo, number) {
298
339
  this.currentPRKey = `${owner}/${repo}#${number}`;
340
+ this._restoreOrCollapsePanel();
299
341
  this.restoreSegmentSelection();
300
342
  this.restoreFilterState();
301
343
  }
@@ -327,6 +369,10 @@ class AIPanel {
327
369
  */
328
370
  setAnalysisState(state) {
329
371
  this.analysisState = state;
372
+ // Auto-expand panel when analysis starts
373
+ if (state === 'loading' && this.isCollapsed) {
374
+ this.expand();
375
+ }
330
376
  // Re-render if currently showing empty state
331
377
  if (this.findings.length === 0 && this.selectedSegment === 'ai') {
332
378
  this.renderFindings();
@@ -1737,3 +1783,8 @@ class AIPanel {
1737
1783
  document.addEventListener('DOMContentLoaded', () => {
1738
1784
  window.aiPanel = new AIPanel();
1739
1785
  });
1786
+
1787
+ // Export for testing
1788
+ if (typeof module !== 'undefined' && module.exports) {
1789
+ module.exports = { AIPanel };
1790
+ }
@@ -28,7 +28,7 @@ class ChatPanel {
28
28
  this._contextItemId = null; // suggestion ID or comment ID from context
29
29
  this._contextLineMeta = null; // { file, line_start, line_end } — set when opened with line context
30
30
  this._pendingActionContext = null; // { type, itemId } — set by action button handlers, consumed by sendMessage
31
- this._resizeConfig = { min: 300, default: 400, storageKey: 'chat-panel-width' };
31
+ this._resizeConfig = ChatPanel.RESIZE_CONFIG;
32
32
  this._analysisContextRemoved = false;
33
33
  this._sessionAnalysisRunId = null; // tracks which AI run ID's context is loaded in the current session
34
34
  this._openPromise = null; // concurrency guard for open()
@@ -3007,6 +3007,9 @@ class ChatPanel {
3007
3007
  }
3008
3008
  }
3009
3009
 
3010
+ /** Resize configuration for the chat panel, exposed as a static for cross-module use. */
3011
+ ChatPanel.RESIZE_CONFIG = { min: 300, default: 400, cssVar: '--chat-panel-width', storageKey: 'chat-panel-width' };
3012
+
3010
3013
  // Make ChatPanel available globally
3011
3014
  window.ChatPanel = ChatPanel;
3012
3015
 
@@ -41,6 +41,7 @@ class PanelGroup {
41
41
  this._reviewVisible = !document.getElementById('ai-panel')?.classList.contains('collapsed');
42
42
  this._chatVisible = false;
43
43
  this._popoverVisible = false;
44
+ this._currentPRKey = null; // Set via setPR() for per-review chat persistence
44
45
 
45
46
  // Read persisted layout
46
47
  const savedLayout = localStorage.getItem(PanelGroup.STORAGE_KEY);
@@ -60,11 +61,13 @@ class PanelGroup {
60
61
  window.chatPanel = this.chatPanel;
61
62
 
62
63
  // Create a full-height group resize handle for vertical layouts.
63
- // Uses data-panel="ai-panel" so the existing PanelResizer picks it up automatically.
64
+ // Uses data-panel="panel-group" so PanelResizer updates both --ai-panel-width
65
+ // and --chat-panel-width in tandem (needed because the group CSS uses
66
+ // `width: max(--ai-panel-width, --chat-panel-width)`).
64
67
  if (this.groupEl) {
65
68
  this._groupResizeHandle = document.createElement('div');
66
69
  this._groupResizeHandle.className = 'panel-group-resize-handle resize-handle resize-handle-left';
67
- this._groupResizeHandle.dataset.panel = 'ai-panel';
70
+ this._groupResizeHandle.dataset.panel = 'panel-group';
68
71
  this.groupEl.insertBefore(this._groupResizeHandle, this.groupEl.firstChild);
69
72
  }
70
73
 
@@ -77,20 +80,17 @@ class PanelGroup {
77
80
  // Apply initial layout
78
81
  this._applyLayout(this._layout);
79
82
 
80
- // Restore chat visibility from last session (only if chat is available)
81
- const chatState = document.documentElement.getAttribute('data-chat');
82
- if (chatState === 'available') {
83
- this._restoreChatFromStorage();
84
- } else {
85
- // Chat not available yet — zero out CSS variable so max-width calcs are correct.
86
- document.documentElement.style.setProperty('--chat-panel-width', '0px');
87
- }
83
+ // Chat starts hidden; per-review state restored when setPR() is called.
84
+ document.documentElement.style.setProperty('--chat-panel-width', '0px');
88
85
 
89
86
  // Listen for late chat-state transitions (config fetch may complete after constructor)
90
87
  window.addEventListener('chat-state-changed', (e) => {
91
88
  const state = e.detail?.state;
92
89
  if (state === 'available') {
93
- this._restoreChatFromStorage();
90
+ // Only restore if PR context is already set (setPR was called before chat became available)
91
+ if (this._currentPRKey) {
92
+ this._restoreChatFromStorage();
93
+ }
94
94
  } else if (state === 'unavailable' && this.chatToggleBtn) {
95
95
  this.chatToggleBtn.title = 'Install and configure Pi to enable chat';
96
96
  }
@@ -420,10 +420,34 @@ class PanelGroup {
420
420
  }
421
421
 
422
422
  /**
423
- * Restore chat visibility from localStorage (called when chat becomes available)
423
+ * Set the current PR for per-review chat visibility persistence.
424
+ * Call this when a PR/review loads (after aiPanel.setPR).
425
+ * @param {string} prKey - e.g. "owner/repo#123" or "local/local#2"
426
+ */
427
+ setPR(prKey) {
428
+ this._currentPRKey = prKey;
429
+ // Now that we know the review context, restore chat state if chat is available
430
+ if (this._isChatAvailable()) {
431
+ this._restoreChatFromStorage();
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Get the per-review localStorage key for chat visibility.
437
+ * @returns {string|null} Storage key or null if no PR context
438
+ */
439
+ _getChatVisibleStorageKey() {
440
+ if (!this._currentPRKey) return null;
441
+ return `${PanelGroup.CHAT_VISIBLE_KEY}_${this._currentPRKey}`;
442
+ }
443
+
444
+ /**
445
+ * Restore chat visibility from localStorage (per-review).
446
+ * For new reviews (no saved state), chat stays hidden.
424
447
  */
425
448
  _restoreChatFromStorage() {
426
- const savedChatVisible = localStorage.getItem(PanelGroup.CHAT_VISIBLE_KEY);
449
+ const key = this._getChatVisibleStorageKey();
450
+ const savedChatVisible = key ? localStorage.getItem(key) : null;
427
451
  if (savedChatVisible === 'true') {
428
452
  this._chatVisible = true;
429
453
  this.chatPanel.open({ suppressFocus: true });
@@ -476,8 +500,11 @@ class PanelGroup {
476
500
  this.chatToggleBtn.classList.toggle('active', visible);
477
501
  }
478
502
 
479
- // Persist chat visibility
480
- localStorage.setItem(PanelGroup.CHAT_VISIBLE_KEY, visible ? 'true' : 'false');
503
+ // Persist chat visibility (per-review)
504
+ const chatKey = this._getChatVisibleStorageKey();
505
+ if (chatKey) {
506
+ localStorage.setItem(chatKey, visible ? 'true' : 'false');
507
+ }
481
508
 
482
509
  // Clear inline flex heights so the remaining panel fills the space
483
510
  if (this._layout.startsWith('v-')) {
@@ -374,6 +374,7 @@ class LocalManager {
374
374
  if (data.running && data.analysisId) {
375
375
  manager.currentAnalysisId = data.analysisId;
376
376
  manager.isAnalyzing = true;
377
+ window.aiPanel?.setAnalysisState('loading');
377
378
  manager.setButtonAnalyzing(data.analysisId);
378
379
 
379
380
  // Show the appropriate progress modal
@@ -791,10 +792,11 @@ class LocalManager {
791
792
  window.aiPanel = new window.AIPanel();
792
793
  }
793
794
 
794
- // Set local context for AI Panel (restores filter state from localStorage)
795
+ // Set local context for AI Panel and Panel Group (restores per-review state from localStorage)
795
796
  if (window.aiPanel?.setPR) {
796
797
  window.aiPanel.setPR('local', reviewData.repository, this.reviewId);
797
798
  }
799
+ window.panelGroup?.setPR(`local/${reviewData.repository}#${this.reviewId}`);
798
800
 
799
801
  // Load saved comments using the restored filter state from AI Panel
800
802
  const includeDismissed = window.aiPanel?.showDismissedComments || false;
@@ -26,6 +26,30 @@ window.PanelResizer = (function() {
26
26
  // Note: chat-panel resize is handled by ChatPanel itself (see ChatPanel._bindResizeEvents)
27
27
  };
28
28
 
29
+ // panel-group is a virtual panel used by the vertical-layout group resize handle.
30
+ // In vertical mode the group width is `max(--ai-panel-width, --chat-panel-width)`,
31
+ // so dragging the group handle must update BOTH CSS vars in tandem.
32
+ //
33
+ // Built lazily because ChatPanel.RESIZE_CONFIG is defined after this IIFE runs.
34
+ let _panelGroupConfig = null;
35
+ function getPanelGroupConfig() {
36
+ if (!_panelGroupConfig) {
37
+ const aiCfg = CONFIG['ai-panel'];
38
+ // ChatPanel.RESIZE_CONFIG is the canonical source for chat-panel sizing.
39
+ const chatCfg = window.ChatPanel?.RESIZE_CONFIG
40
+ ?? { cssVar: '--chat-panel-width', storageKey: 'chat-panel-width', default: 400, min: 300 };
41
+ const panels = [
42
+ { cssVar: aiCfg.cssVar, storageKey: aiCfg.storageKey, default: aiCfg.default },
43
+ { cssVar: chatCfg.cssVar, storageKey: chatCfg.storageKey, default: chatCfg.default }
44
+ ];
45
+ _panelGroupConfig = {
46
+ min: Math.max(aiCfg.min, chatCfg.min),
47
+ panels
48
+ };
49
+ }
50
+ return _panelGroupConfig;
51
+ }
52
+
29
53
  /**
30
54
  * Compute the effective max width for a panel.
31
55
  * For panels with a static max, returns that value.
@@ -39,6 +63,39 @@ window.PanelResizer = (function() {
39
63
  return window.innerWidth - sidebarWidth - 100;
40
64
  }
41
65
 
66
+ /**
67
+ * Get the current panel-group width (the max of its sub-panel CSS vars).
68
+ * This matches the CSS `max()` expression used in vertical layouts.
69
+ * @returns {number} Current group width in pixels
70
+ */
71
+ function getPanelGroupWidth() {
72
+ let maxWidth = 0;
73
+ for (const p of getPanelGroupConfig().panels) {
74
+ const val = parseInt(
75
+ getComputedStyle(document.documentElement).getPropertyValue(p.cssVar), 10
76
+ ) || p.default;
77
+ if (val > maxWidth) maxWidth = val;
78
+ }
79
+ return maxWidth;
80
+ }
81
+
82
+ /**
83
+ * Set the panel-group width by updating ALL sub-panel CSS vars in tandem.
84
+ * @param {number} width - Desired width in pixels
85
+ * @param {boolean} save - Whether to persist to localStorage
86
+ */
87
+ function setPanelGroupWidth(width, save = true) {
88
+ const effectiveMax = getEffectiveMax('ai-panel'); // same viewport constraint
89
+ const clamped = Math.max(getPanelGroupConfig().min, Math.min(effectiveMax, width));
90
+
91
+ for (const p of getPanelGroupConfig().panels) {
92
+ document.documentElement.style.setProperty(p.cssVar, `${clamped}px`);
93
+ if (save) {
94
+ localStorage.setItem(p.storageKey, clamped.toString());
95
+ }
96
+ }
97
+ }
98
+
42
99
  // State
43
100
  let isDragging = false;
44
101
  let currentPanel = null;
@@ -127,6 +184,20 @@ window.PanelResizer = (function() {
127
184
  if (!handle) return;
128
185
 
129
186
  const panelName = handle.dataset.panel;
187
+
188
+ // panel-group: virtual panel — start width is the max of the sub-panels
189
+ if (panelName === 'panel-group') {
190
+ isDragging = true;
191
+ currentPanel = panelName;
192
+ startX = e.clientX;
193
+ startWidth = getPanelGroupWidth();
194
+
195
+ handle.classList.add('dragging');
196
+ document.body.classList.add('resizing');
197
+ e.preventDefault();
198
+ return;
199
+ }
200
+
130
201
  const panelEl = panelName === 'sidebar'
131
202
  ? document.getElementById('files-sidebar')
132
203
  : panelName === 'chat-panel'
@@ -157,6 +228,14 @@ window.PanelResizer = (function() {
157
228
  function onMouseMove(e) {
158
229
  if (!isDragging || !currentPanel) return;
159
230
 
231
+ // panel-group: update both sub-panel CSS vars in tandem
232
+ if (currentPanel === 'panel-group') {
233
+ const delta = startX - e.clientX; // right-side panel: left = wider
234
+ const newWidth = startWidth + delta;
235
+ setPanelGroupWidth(newWidth, false);
236
+ return;
237
+ }
238
+
160
239
  const config = CONFIG[currentPanel];
161
240
  if (!config) return;
162
241
 
@@ -182,7 +261,11 @@ window.PanelResizer = (function() {
182
261
  if (!isDragging) return;
183
262
 
184
263
  // Save final width
185
- if (currentPanel) {
264
+ if (currentPanel === 'panel-group') {
265
+ // Persist both sub-panel widths
266
+ const finalWidth = getPanelGroupWidth();
267
+ setPanelGroupWidth(finalWidth, true);
268
+ } else if (currentPanel) {
186
269
  const finalWidth = getPanelWidth(currentPanel);
187
270
  const config = CONFIG[currentPanel];
188
271
  if (config) {
package/public/js/pr.js CHANGED
@@ -368,6 +368,9 @@ class PRManager {
368
368
 
369
369
  /**
370
370
  * Auto-trigger analysis if ?analyze=true is present in the URL.
371
+ * Skips refresh if data was just loaded fresh by loadPR (to avoid redundant fetches).
372
+ * Otherwise, refreshes PR data first to ensure we analyze the latest code.
373
+ * If refresh fails, proceeds with existing data rather than failing entirely.
371
374
  * Cleans up the query parameter afterwards regardless of success or failure.
372
375
  * @param {string} owner - Repository owner
373
376
  * @param {string} repo - Repository name
@@ -378,6 +381,21 @@ class PRManager {
378
381
  if (autoAnalyze === 'true' && !this.isAnalyzing) {
379
382
  this._autoAnalyzeRequested = true;
380
383
  try {
384
+ // Skip refresh if we just loaded fresh data (loadPR sets _justLoaded = true).
385
+ // Otherwise, refresh to ensure we have the latest PR data in case the worktree
386
+ // already existed but the PR has new commits since last load.
387
+ if (this._justLoaded) {
388
+ this._justLoaded = false;
389
+ } else {
390
+ try {
391
+ await this.refreshPR();
392
+ } catch (e) {
393
+ // If refresh fails, proceed with existing data - this is intentional.
394
+ // We'd rather analyze stale data than fail entirely.
395
+ console.warn('Pre-analysis refresh failed, proceeding with existing data', e);
396
+ }
397
+ }
398
+
381
399
  await this.startAnalysis(owner, repo, prNumber, null, {});
382
400
  } finally {
383
401
  this._autoAnalyzeRequested = false;
@@ -426,11 +444,12 @@ class PRManager {
426
444
  window.aiPanel = new window.AIPanel();
427
445
  }
428
446
 
429
- // Set PR context for AI Panel (for PR-specific localStorage keys)
430
- // This restores the filter state from localStorage
447
+ // Set PR context for AI Panel and Panel Group (for per-review localStorage keys)
448
+ // This restores the filter state and chat visibility from localStorage
431
449
  if (window.aiPanel?.setPR) {
432
450
  window.aiPanel.setPR(owner, repo, number);
433
451
  }
452
+ window.panelGroup?.setPR(`${owner}/${repo}#${number}`);
434
453
 
435
454
  // Load saved comments using the restored filter state from AI Panel
436
455
  // If AI Panel has showDismissedComments=true (restored from localStorage), use that
@@ -470,6 +489,8 @@ class PRManager {
470
489
  this.showError(error.message);
471
490
  } finally {
472
491
  this.setLoading(false);
492
+ // Mark that we just loaded fresh data - used by _maybeAutoAnalyze to skip redundant refresh
493
+ this._justLoaded = true;
473
494
  }
474
495
  }
475
496
 
@@ -1400,6 +1421,15 @@ class PRManager {
1400
1421
  }
1401
1422
  } else {
1402
1423
  this.viewedFiles.delete(filePath);
1424
+ // Auto-expand when unchecking viewed (match GitHub behavior)
1425
+ if (wrapper && wrapper.classList.contains('collapsed')) {
1426
+ wrapper.classList.remove('collapsed');
1427
+ this.collapsedFiles.delete(filePath);
1428
+ const header = wrapper.querySelector('.d2h-file-header');
1429
+ if (header) {
1430
+ window.DiffRenderer.updateFileHeaderState(header, true);
1431
+ }
1432
+ }
1403
1433
  }
1404
1434
 
1405
1435
  // Persist viewed state
package/public/local.html CHANGED
@@ -404,7 +404,7 @@
404
404
  <!-- Right Panel Group (AI Panel + Chat) -->
405
405
  <div class="right-panel-group" id="right-panel-group">
406
406
  <!-- AI Analysis Panel (Right) -->
407
- <aside class="ai-panel" id="ai-panel">
407
+ <aside class="ai-panel collapsed" id="ai-panel">
408
408
  <div class="resize-handle resize-handle-left" data-panel="ai-panel"></div>
409
409
  <div class="ai-panel-header">
410
410
  <div class="ai-panel-title">
package/public/pr.html CHANGED
@@ -227,7 +227,7 @@
227
227
  <!-- Right Panel Group (AI Panel + Chat) -->
228
228
  <div class="right-panel-group" id="right-panel-group">
229
229
  <!-- AI Analysis Panel (Right) -->
230
- <aside class="ai-panel" id="ai-panel">
230
+ <aside class="ai-panel collapsed" id="ai-panel">
231
231
  <div class="resize-handle resize-handle-left" data-panel="ai-panel"></div>
232
232
  <div class="ai-panel-header">
233
233
  <div class="ai-panel-title">