@in-the-loop-labs/pair-review 1.3.0 → 1.3.1

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": "1.3.0",
3
+ "version": "1.3.1",
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": "1.3.0",
3
+ "version": "1.3.1",
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": "1.3.0",
3
+ "version": "1.3.1",
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/index.html CHANGED
@@ -746,6 +746,57 @@
746
746
  font-size: 14px;
747
747
  }
748
748
 
749
+ /* Show More button for pagination */
750
+ .show-more-container {
751
+ display: flex;
752
+ justify-content: center;
753
+ padding: 16px 0 0;
754
+ }
755
+
756
+ .btn-show-more {
757
+ display: inline-flex;
758
+ align-items: center;
759
+ gap: 8px;
760
+ padding: 8px 24px;
761
+ font-family: var(--font-sans);
762
+ font-size: 13px;
763
+ font-weight: 500;
764
+ color: var(--color-text-secondary);
765
+ background: var(--color-bg-primary);
766
+ border: 1px solid var(--color-border-primary);
767
+ border-radius: var(--radius-md);
768
+ cursor: pointer;
769
+ transition: all var(--transition-fast);
770
+ }
771
+
772
+ .btn-show-more:hover:not(:disabled) {
773
+ background: var(--color-bg-secondary);
774
+ color: var(--color-text-primary);
775
+ }
776
+
777
+ .btn-show-more:disabled {
778
+ opacity: 0.6;
779
+ cursor: not-allowed;
780
+ }
781
+
782
+ .btn-show-more .spinner {
783
+ width: 14px;
784
+ height: 14px;
785
+ border: 2px solid var(--color-border-primary);
786
+ border-top-color: var(--color-accent-primary);
787
+ border-radius: 50%;
788
+ animation: spin 0.8s linear infinite;
789
+ display: none;
790
+ }
791
+
792
+ .btn-show-more.loading .spinner {
793
+ display: block;
794
+ }
795
+
796
+ .btn-show-more.loading .btn-show-more-text {
797
+ display: none;
798
+ }
799
+
749
800
  /* Initial loading state - hide content until JS determines what to show */
750
801
  .loading-hidden {
751
802
  display: none !important;
@@ -1081,18 +1132,36 @@
1081
1132
  }
1082
1133
  }
1083
1134
 
1084
- // Event delegation for delete buttons
1135
+ // Event delegation for delete buttons and show-more button
1085
1136
  document.addEventListener('click', function(event) {
1086
1137
  const deleteBtn = event.target.closest('.btn-delete-worktree');
1087
1138
  if (deleteBtn) {
1088
1139
  event.preventDefault();
1089
1140
  event.stopPropagation();
1090
1141
  deleteWorktree(deleteBtn);
1142
+ return;
1143
+ }
1144
+
1145
+ const showMoreBtn = event.target.closest('#btn-show-more');
1146
+ if (showMoreBtn) {
1147
+ event.preventDefault();
1148
+ loadMoreReviews();
1091
1149
  }
1092
1150
  });
1093
1151
 
1152
+ /** Pagination state for the recent reviews list */
1153
+ const recentReviewsPagination = {
1154
+ /** ISO timestamp of the last loaded item (cursor for next fetch) */
1155
+ lastTimestamp: null,
1156
+ /** Number of worktrees to fetch per page */
1157
+ pageSize: 10,
1158
+ /** Whether the server has indicated more results exist */
1159
+ hasMore: false
1160
+ };
1161
+
1094
1162
  /**
1095
- * Fetch and display recent reviews
1163
+ * Fetch and display recent reviews (initial load).
1164
+ * Resets pagination state and renders the full table from scratch.
1096
1165
  */
1097
1166
  async function loadRecentReviews() {
1098
1167
  const container = document.getElementById('recent-reviews-container');
@@ -1100,8 +1169,12 @@
1100
1169
  const sectionHeader = document.getElementById('recent-reviews-header');
1101
1170
  const usageInfo = document.getElementById('usage-info');
1102
1171
 
1172
+ // Reset pagination state
1173
+ recentReviewsPagination.lastTimestamp = null;
1174
+ recentReviewsPagination.hasMore = false;
1175
+
1103
1176
  try {
1104
- const response = await fetch('/api/worktrees/recent?limit=10');
1177
+ const response = await fetch(`/api/worktrees/recent?limit=${recentReviewsPagination.pageSize}`);
1105
1178
 
1106
1179
  if (!response.ok) {
1107
1180
  throw new Error('Failed to fetch recent reviews');
@@ -1123,6 +1196,10 @@
1123
1196
  return;
1124
1197
  }
1125
1198
 
1199
+ // Update pagination state — track the cursor for the next page
1200
+ recentReviewsPagination.lastTimestamp = data.worktrees[data.worktrees.length - 1].last_accessed_at;
1201
+ recentReviewsPagination.hasMore = !!data.hasMore;
1202
+
1126
1203
  // Hide usage info when there are reviews (keep loading-hidden class)
1127
1204
  // Show the section header
1128
1205
  if (sectionHeader) sectionHeader.classList.remove('loading-hidden');
@@ -1140,10 +1217,11 @@
1140
1217
  <th>Actions</th>
1141
1218
  </tr>
1142
1219
  </thead>
1143
- <tbody>
1220
+ <tbody id="recent-reviews-tbody">
1144
1221
  ${data.worktrees.map(renderRecentReviewRow).join('')}
1145
1222
  </tbody>
1146
1223
  </table>
1224
+ ${renderShowMoreButton(data.hasMore)}
1147
1225
  `;
1148
1226
  container.innerHTML = html;
1149
1227
  container.classList.remove('recent-reviews-loading');
@@ -1156,6 +1234,90 @@
1156
1234
  }
1157
1235
  }
1158
1236
 
1237
+ /**
1238
+ * Render the "Show more" button HTML.
1239
+ * @param {boolean} hasMore - Whether more results are available
1240
+ * @returns {string} HTML string for the show-more container
1241
+ */
1242
+ function renderShowMoreButton(hasMore) {
1243
+ if (!hasMore) return '';
1244
+ return `
1245
+ <div class="show-more-container" id="show-more-container">
1246
+ <button class="btn-show-more" id="btn-show-more" type="button">
1247
+ <span class="btn-show-more-text">Show more</span>
1248
+ <span class="spinner"></span>
1249
+ </button>
1250
+ </div>
1251
+ `;
1252
+ }
1253
+
1254
+ /**
1255
+ * Load the next page of worktrees and append them to the existing table.
1256
+ * Called when the "Show more" button is clicked.
1257
+ */
1258
+ async function loadMoreReviews() {
1259
+ const btn = document.getElementById('btn-show-more');
1260
+ const tbody = document.getElementById('recent-reviews-tbody');
1261
+ if (!btn || !tbody) return;
1262
+
1263
+ // Show loading state on the button
1264
+ btn.classList.add('loading');
1265
+ btn.disabled = true;
1266
+
1267
+ try {
1268
+ const { lastTimestamp, pageSize } = recentReviewsPagination;
1269
+ const params = new URLSearchParams({ limit: pageSize });
1270
+ if (lastTimestamp) params.set('before', lastTimestamp);
1271
+ const response = await fetch(`/api/worktrees/recent?${params}`);
1272
+
1273
+ if (!response.ok) {
1274
+ throw new Error('Failed to fetch more reviews');
1275
+ }
1276
+
1277
+ const data = await response.json();
1278
+
1279
+ // Guard against stale response if the table was refreshed (e.g. by a delete) while loading
1280
+ if (!document.contains(btn)) return;
1281
+
1282
+ if (!data.success || !data.worktrees || data.worktrees.length === 0) {
1283
+ // No more results - remove the button
1284
+ const showMoreContainer = document.getElementById('show-more-container');
1285
+ if (showMoreContainer) showMoreContainer.remove();
1286
+ recentReviewsPagination.hasMore = false;
1287
+ return;
1288
+ }
1289
+
1290
+ // Append new rows to the existing table body
1291
+ tbody.insertAdjacentHTML('beforeend', data.worktrees.map(renderRecentReviewRow).join(''));
1292
+
1293
+ // Update pagination state — advance the cursor
1294
+ recentReviewsPagination.lastTimestamp = data.worktrees[data.worktrees.length - 1].last_accessed_at;
1295
+ recentReviewsPagination.hasMore = !!data.hasMore;
1296
+
1297
+ // Update or remove the "Show more" button
1298
+ if (!data.hasMore) {
1299
+ const showMoreContainer = document.getElementById('show-more-container');
1300
+ if (showMoreContainer) showMoreContainer.remove();
1301
+ } else {
1302
+ // Reset button state
1303
+ btn.classList.remove('loading');
1304
+ btn.disabled = false;
1305
+ }
1306
+
1307
+ } catch (error) {
1308
+ console.error('Error loading more reviews:', error);
1309
+ // Reset button state and show error so the user knows what happened
1310
+ btn.classList.remove('loading');
1311
+ btn.disabled = false;
1312
+ const textEl = btn.querySelector('.btn-show-more-text');
1313
+ if (textEl) {
1314
+ textEl.textContent = 'Failed to load — click to retry';
1315
+ // Restore original text after a brief delay so the user sees the error
1316
+ setTimeout(() => { textEl.textContent = 'Show more'; }, 4000);
1317
+ }
1318
+ }
1319
+ }
1320
+
1159
1321
  /**
1160
1322
  * Parse a PR URL using the backend API
1161
1323
  * Supports GitHub and Graphite URLs (with or without protocol)
@@ -1192,6 +1192,15 @@ class LocalManager {
1192
1192
  // Reload the diff display
1193
1193
  await this.loadLocalDiff();
1194
1194
 
1195
+ // Re-render comments and AI suggestions on the fresh DOM
1196
+ // (renderDiff clears the diff container, so we must re-populate)
1197
+ const includeDismissed = window.aiPanel?.showDismissedComments || false;
1198
+ await manager.loadUserComments(includeDismissed);
1199
+ // Note: Unlike loadLocalReview() which skips this when analysisHistoryManager exists
1200
+ // (because the manager triggers loadAISuggestions via onSelectionChange on init),
1201
+ // refresh must call unconditionally since the manager won't re-fire its callback.
1202
+ await manager.loadAISuggestions(null, manager.selectedRunId);
1203
+
1195
1204
  // Show success toast
1196
1205
  if (window.toast) {
1197
1206
  window.toast.showSuccess('Diff refreshed successfully');
package/public/js/pr.js CHANGED
@@ -3676,6 +3676,15 @@ class PRManager {
3676
3676
  // Reload the files/diff with fresh data
3677
3677
  await this.loadAndDisplayFiles(owner, repo, number);
3678
3678
 
3679
+ // Re-render comments and AI suggestions on the fresh DOM
3680
+ // (renderDiff clears the diff container, so we must re-populate)
3681
+ const includeDismissed = window.aiPanel?.showDismissedComments || false;
3682
+ await this.loadUserComments(includeDismissed);
3683
+ // Note: Unlike loadPR() which skips this when analysisHistoryManager exists
3684
+ // (because the manager triggers loadAISuggestions via onSelectionChange on init),
3685
+ // refresh must call unconditionally since the manager won't re-fire its callback.
3686
+ await this.loadAISuggestions(null, this.selectedRunId);
3687
+
3679
3688
  // Restore expanded folders
3680
3689
  this.expandedFolders = expandedFolders;
3681
3690
 
@@ -116,33 +116,73 @@ router.post('/api/worktrees/create', async (req, res) => {
116
116
  });
117
117
 
118
118
  /**
119
- * Get recently accessed worktrees
120
- * Returns list of recently reviewed PRs with metadata
121
- * Filters out stale worktrees where the directory no longer exists
119
+ * Get recently accessed worktrees with cursor-based pagination.
120
+ * Returns list of recently reviewed PRs with metadata.
121
+ * Filters out stale worktrees where the directory no longer exists.
122
+ *
123
+ * Query parameters:
124
+ * limit - Number of worktrees to return (default 10, max 50)
125
+ * before - ISO timestamp cursor: return worktrees accessed before this time.
126
+ * For subsequent pages, send the last_accessed_at of the last item
127
+ * from the previous page. Omit for the initial load.
128
+ *
129
+ * Response includes:
130
+ * worktrees - Array of worktree objects
131
+ * hasMore - Whether more worktrees are available beyond this page
122
132
  */
123
133
  router.get('/api/worktrees/recent', async (req, res) => {
124
134
  try {
125
135
  const limit = Math.min(parseInt(req.query.limit) || 10, 50); // Default 10, max 50
136
+ const before = req.query.before || null; // ISO timestamp cursor
126
137
  const db = req.app.get('db');
127
138
 
128
- // Get more worktrees than requested to account for stale ones we'll filter out
129
- const enrichedWorktrees = await query(db, `
130
- SELECT
131
- w.id,
132
- w.repository,
133
- w.pr_number,
134
- w.branch,
135
- w.path,
136
- w.last_accessed_at,
137
- w.created_at,
138
- pm.title as pr_title,
139
- pm.author,
140
- pm.head_branch
141
- FROM worktrees w
142
- LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
143
- ORDER BY w.last_accessed_at DESC
144
- LIMIT ?
145
- `, [limit * 2]); // Fetch extra to account for stale entries
139
+ // Fetch a constant overshoot per page to account for stale entries
140
+ // that will be filtered out, plus one extra to determine hasMore
141
+ const fetchCount = limit * 3 + 1;
142
+
143
+ let enrichedWorktrees;
144
+ if (before) {
145
+ // Cursor-based: fetch rows older than the cursor.
146
+ // Strict less-than: entries sharing the cursor timestamp may be skipped,
147
+ // acceptable given millisecond precision and small dataset size.
148
+ enrichedWorktrees = await query(db, `
149
+ SELECT
150
+ w.id,
151
+ w.repository,
152
+ w.pr_number,
153
+ w.branch,
154
+ w.path,
155
+ w.last_accessed_at,
156
+ w.created_at,
157
+ pm.title as pr_title,
158
+ pm.author,
159
+ pm.head_branch
160
+ FROM worktrees w
161
+ LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
162
+ WHERE w.last_accessed_at < ?
163
+ ORDER BY w.last_accessed_at DESC
164
+ LIMIT ?
165
+ `, [before, fetchCount]);
166
+ } else {
167
+ // Initial load: no cursor, just fetch from the top
168
+ enrichedWorktrees = await query(db, `
169
+ SELECT
170
+ w.id,
171
+ w.repository,
172
+ w.pr_number,
173
+ w.branch,
174
+ w.path,
175
+ w.last_accessed_at,
176
+ w.created_at,
177
+ pm.title as pr_title,
178
+ pm.author,
179
+ pm.head_branch
180
+ FROM worktrees w
181
+ LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
182
+ ORDER BY w.last_accessed_at DESC
183
+ LIMIT ?
184
+ `, [fetchCount]);
185
+ }
146
186
 
147
187
  // Filter out worktrees where:
148
188
  // 1. The directory no longer exists
@@ -167,8 +207,9 @@ router.get('/api/worktrees/recent', async (req, res) => {
167
207
  }
168
208
  }
169
209
 
170
- // Cleanup stale worktree records in background (don't block response)
171
- if (staleIds.length > 0) {
210
+ // Only run stale cleanup on initial (non-paginated) requests to keep the
211
+ // dataset stable while the user pages through results.
212
+ if (staleIds.length > 0 && !before) {
172
213
  setImmediate(async () => {
173
214
  try {
174
215
  const placeholders = staleIds.map(() => '?').join(',');
@@ -180,8 +221,12 @@ router.get('/api/worktrees/recent', async (req, res) => {
180
221
  });
181
222
  }
182
223
 
183
- // Format the results with fallback values, limited to requested count
184
- const formattedWorktrees = validWorktrees.slice(0, limit).map(w => ({
224
+ // Take the first `limit` valid results; anything beyond means hasMore
225
+ const pageWorktrees = validWorktrees.slice(0, limit);
226
+ const hasMore = validWorktrees.length > limit;
227
+
228
+ // Format the results with fallback values
229
+ const formattedWorktrees = pageWorktrees.map(w => ({
185
230
  id: w.id,
186
231
  repository: w.repository,
187
232
  pr_number: w.pr_number,
@@ -195,12 +240,14 @@ router.get('/api/worktrees/recent', async (req, res) => {
195
240
 
196
241
  res.json({
197
242
  success: true,
198
- worktrees: formattedWorktrees
243
+ worktrees: formattedWorktrees,
244
+ hasMore
199
245
  });
200
246
 
201
247
  } catch (error) {
202
- console.error('Error fetching recent worktrees:', error);
248
+ logger.error('Error fetching recent worktrees:', error);
203
249
  res.status(500).json({
250
+ success: false,
204
251
  error: 'Failed to fetch recent worktrees'
205
252
  });
206
253
  }