@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": "pair-review",
|
|
3
|
-
"version": "1.3.
|
|
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.
|
|
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(
|
|
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)
|
package/public/js/local.js
CHANGED
|
@@ -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
|
|
package/src/routes/worktrees.js
CHANGED
|
@@ -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
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
//
|
|
171
|
-
|
|
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
|
-
//
|
|
184
|
-
const
|
|
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
|
-
|
|
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
|
}
|