@in-the-loop-labs/pair-review 3.5.1 → 3.6.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.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +603 -6
- package/public/index.html +90 -0
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/TourBar.js +248 -0
- package/public/js/index.js +298 -25
- package/public/js/local.js +6 -0
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/tour-renderer.js +725 -0
- package/public/js/pr.js +1276 -2
- package/public/js/utils/modal-detection.js +77 -0
- package/public/local.html +17 -0
- package/public/pr.html +17 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +114 -0
- package/src/database.js +282 -1
- package/src/local-review.js +189 -169
- package/src/routes/config.js +16 -1
- package/src/routes/context-files.js +2 -29
- package/src/routes/github-collections.js +168 -90
- package/src/routes/local.js +311 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +259 -4
- package/src/routes/reviews.js +145 -29
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Shared frontend helpers for cancelling in-flight background jobs
|
|
4
|
+
* (tour and summaries). Both flows share the same dialog -> POST ->
|
|
5
|
+
* reset-button-state shape, so the per-artifact wrappers stay thin.
|
|
6
|
+
*
|
|
7
|
+
* Backend contract (see src/routes/reviews.js handleJobCancel):
|
|
8
|
+
* POST /api/reviews/:reviewId/jobs/:jobKey/cancel
|
|
9
|
+
* 200 -> { cancelled: true, count: N }
|
|
10
|
+
* 404 -> { cancelled: false } (nothing in flight)
|
|
11
|
+
* 400 -> invalid jobKey
|
|
12
|
+
*
|
|
13
|
+
* Local-mode parity: the local route at
|
|
14
|
+
* POST /api/local/:reviewId/jobs/:jobKey/cancel
|
|
15
|
+
* is a thin wrapper. Both modes share the `reviews` table, so we can
|
|
16
|
+
* use ONE endpoint here. Local mode is detected via document.body.dataset
|
|
17
|
+
* to keep this module independent of PRManager internals.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
(function () {
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* POST the cancel request for a single (reviewId, jobKey) pair.
|
|
25
|
+
* Returns a Promise resolving to the parsed JSON response.
|
|
26
|
+
*
|
|
27
|
+
* @param {number|string} reviewId
|
|
28
|
+
* @param {string} jobKey - bare prefix (`tour` | `summaries`) or full
|
|
29
|
+
* suffix (`summaries:<digest>`); the backend treats bare prefixes as
|
|
30
|
+
* "cancel all matching".
|
|
31
|
+
* @returns {Promise<{ok: boolean, status: number, body: any}>}
|
|
32
|
+
*/
|
|
33
|
+
async function postCancel(reviewId, jobKey) {
|
|
34
|
+
if (!reviewId) {
|
|
35
|
+
return { ok: false, status: 0, body: { error: 'missing reviewId' } };
|
|
36
|
+
}
|
|
37
|
+
// Both modes share the /api/reviews/... endpoint thanks to the
|
|
38
|
+
// shared reviews table. No need to branch on local-vs-PR mode here.
|
|
39
|
+
const url = `/api/reviews/${reviewId}/jobs/${encodeURIComponent(jobKey)}/cancel`;
|
|
40
|
+
try {
|
|
41
|
+
const resp = await fetch(url, { method: 'POST' });
|
|
42
|
+
let body = null;
|
|
43
|
+
try {
|
|
44
|
+
body = await resp.json();
|
|
45
|
+
} catch {
|
|
46
|
+
body = null;
|
|
47
|
+
}
|
|
48
|
+
return { ok: resp.ok, status: resp.status, body };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Network or unexpected fetch error — log and surface as failure.
|
|
51
|
+
// We deliberately do not toast here so callers can decide the UX.
|
|
52
|
+
// eslint-disable-next-line no-console
|
|
53
|
+
console.warn(`[cancel-background-job] POST ${url} failed:`, err);
|
|
54
|
+
return { ok: false, status: 0, body: { error: err && err.message } };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Open the shared ConfirmDialog with cancel-job copy, then on confirm
|
|
60
|
+
* POST the cancel and invoke the caller's `onCancelled` callback so it
|
|
61
|
+
* can sync UI state (button class toggles, in-memory `_generating`
|
|
62
|
+
* flags, etc.).
|
|
63
|
+
*
|
|
64
|
+
* The caller's `onCancelled` runs only when the backend confirms the
|
|
65
|
+
* cancel reached a terminal state: 200 (cancelled) or 404 (already gone).
|
|
66
|
+
* For any other HTTP status (400 validation, 500 server error, etc.) or
|
|
67
|
+
* a network failure, we toast an error and leave the active state intact
|
|
68
|
+
* so the pulse stays visible and the user can retry the cancel click.
|
|
69
|
+
*
|
|
70
|
+
* @param {Object} opts
|
|
71
|
+
* @param {number|string} opts.reviewId
|
|
72
|
+
* @param {string} opts.jobKey - `tour` or `summaries`
|
|
73
|
+
* @param {string} opts.title - Dialog title (e.g. "Tour is still generating")
|
|
74
|
+
* @param {string} opts.message - Dialog body
|
|
75
|
+
* @param {string} opts.confirmText - Confirm button label (e.g. "Cancel Tour")
|
|
76
|
+
* @param {Function} opts.onCancelled - Called after a confirmed cancel.
|
|
77
|
+
* @returns {Promise<void>}
|
|
78
|
+
*/
|
|
79
|
+
async function showCancelJobDialog(opts) {
|
|
80
|
+
const { reviewId, jobKey, title, message, confirmText, onCancelled } = opts || {};
|
|
81
|
+
const dialog = typeof window !== 'undefined' ? window.confirmDialog : null;
|
|
82
|
+
if (!dialog || typeof dialog.show !== 'function') {
|
|
83
|
+
// ConfirmDialog hasn't initialized yet — bail silently. The button
|
|
84
|
+
// still works on its second click (after DOMContentLoaded fires).
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const result = await dialog.show({
|
|
88
|
+
title: title || 'Generation in progress',
|
|
89
|
+
message: message || 'Cancel this job?',
|
|
90
|
+
confirmText: confirmText || 'Cancel',
|
|
91
|
+
confirmClass: 'btn-danger',
|
|
92
|
+
cancelText: 'OK',
|
|
93
|
+
});
|
|
94
|
+
if (result !== 'confirm') return;
|
|
95
|
+
|
|
96
|
+
const { status, body } = await postCancel(reviewId, jobKey);
|
|
97
|
+
// ONLY 200 (cancelled) and 404 (already gone) are UI-clearing outcomes.
|
|
98
|
+
// Anything else (400, 500, 503, network failure with status=0) means
|
|
99
|
+
// the job may still be running — keep the pulse, toast an error, and
|
|
100
|
+
// let the user re-click to retry.
|
|
101
|
+
if (status !== 200 && status !== 404) {
|
|
102
|
+
// eslint-disable-next-line no-console
|
|
103
|
+
console.error(
|
|
104
|
+
`[cancel-background-job] cancel failed for ${jobKey} (status ${status}):`,
|
|
105
|
+
body
|
|
106
|
+
);
|
|
107
|
+
if (typeof window !== 'undefined' && window.toast?.error) {
|
|
108
|
+
const detail = body && body.error ? `: ${body.error}` : '';
|
|
109
|
+
const msg = status === 0
|
|
110
|
+
? 'Failed to cancel — check connection'
|
|
111
|
+
: `Failed to cancel (HTTP ${status})${detail}`;
|
|
112
|
+
window.toast.error(msg);
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// 200 or 404 — terminal. Reset UI state.
|
|
117
|
+
if (typeof onCancelled === 'function') {
|
|
118
|
+
try {
|
|
119
|
+
onCancelled({ cancelled: status === 200 && body && body.cancelled, status });
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// eslint-disable-next-line no-console
|
|
122
|
+
console.warn('[cancel-background-job] onCancelled handler threw:', err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- Per-artifact wrappers (thin convenience layer) --------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Open the "Cancel Tour" confirm dialog.
|
|
131
|
+
* @param {Object} opts
|
|
132
|
+
* @param {number|string} opts.reviewId
|
|
133
|
+
* @param {Function} opts.onCancelled
|
|
134
|
+
* @returns {Promise<void>}
|
|
135
|
+
*/
|
|
136
|
+
function showCancelTourDialog(opts) {
|
|
137
|
+
return showCancelJobDialog({
|
|
138
|
+
reviewId: opts.reviewId,
|
|
139
|
+
jobKey: 'tour',
|
|
140
|
+
title: 'Tour is still being generated',
|
|
141
|
+
message:
|
|
142
|
+
'A guided tour is still being generated for this review. ' +
|
|
143
|
+
'Cancelling will stop the upstream AI call.',
|
|
144
|
+
confirmText: 'Cancel Tour',
|
|
145
|
+
onCancelled: opts.onCancelled,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Open the "Cancel Summaries" confirm dialog.
|
|
151
|
+
* @param {Object} opts
|
|
152
|
+
* @param {number|string} opts.reviewId
|
|
153
|
+
* @param {Function} opts.onCancelled
|
|
154
|
+
* @returns {Promise<void>}
|
|
155
|
+
*/
|
|
156
|
+
function showCancelSummariesDialog(opts) {
|
|
157
|
+
return showCancelJobDialog({
|
|
158
|
+
reviewId: opts.reviewId,
|
|
159
|
+
jobKey: 'summaries',
|
|
160
|
+
title: 'Summaries are still being generated',
|
|
161
|
+
message:
|
|
162
|
+
'Hunk summaries are still being generated for this review. ' +
|
|
163
|
+
'Cancelling will stop the upstream AI call. Summaries already ' +
|
|
164
|
+
'persisted will remain.',
|
|
165
|
+
confirmText: 'Cancel Summaries',
|
|
166
|
+
onCancelled: opts.onCancelled,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const api = {
|
|
171
|
+
postCancel,
|
|
172
|
+
showCancelJobDialog,
|
|
173
|
+
showCancelTourDialog,
|
|
174
|
+
showCancelSummariesDialog,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (typeof window !== 'undefined') {
|
|
178
|
+
window.CancelBackgroundJob = api;
|
|
179
|
+
}
|
|
180
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
181
|
+
module.exports = api;
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* HunkSummaryRenderer - Renders inline natural-language hunk summaries.
|
|
4
|
+
*
|
|
5
|
+
* A summary is a short natural-language description of a non-trivial hunk,
|
|
6
|
+
* generated by a background AI job. Renderer responsibilities:
|
|
7
|
+
* - Insert a styled annotation row immediately above the hunk's first code row
|
|
8
|
+
* - Keep the mounted-row map in sync so re-renders update text in place
|
|
9
|
+
* - Remove an annotation when its underlying hunk goes away (file re-render)
|
|
10
|
+
*
|
|
11
|
+
* Visibility is controlled outside the renderer via CSS classes:
|
|
12
|
+
* - body.summaries-hidden — review-level toggle
|
|
13
|
+
* - .d2h-file-wrapper.summaries-hidden-file — per-file toggle
|
|
14
|
+
*
|
|
15
|
+
* Trivial / model-skipped / model-malformed rows are filtered out by the
|
|
16
|
+
* caller; this renderer only handles rows with non-empty `summary_text`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
class HunkSummaryRenderer {
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} prManager - PRManager instance (kept for future callbacks).
|
|
22
|
+
*/
|
|
23
|
+
constructor(prManager) {
|
|
24
|
+
this.prManager = prManager;
|
|
25
|
+
// Map<contentHash, HTMLTableRowElement> — annotation rows currently mounted
|
|
26
|
+
this._mounted = new Map();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Render a summary inline, immediately above its hunk anchor row.
|
|
31
|
+
* Idempotent: if a summary for the same hash is already mounted, replace
|
|
32
|
+
* its text rather than stack a duplicate.
|
|
33
|
+
*
|
|
34
|
+
* @param {HTMLTableRowElement} anchorRow - The first code-line `<tr>` of
|
|
35
|
+
* the target hunk (carries `data-hunk-start="${contentHash}"`).
|
|
36
|
+
* @param {Object} summary - Summary object: { content_hash, summary_text }.
|
|
37
|
+
* @param {Object} [_opts] - Reserved for future per-render options.
|
|
38
|
+
* @returns {HTMLTableRowElement|null}
|
|
39
|
+
*/
|
|
40
|
+
renderInline(anchorRow, summary, _opts) {
|
|
41
|
+
if (!anchorRow || !summary || !summary.summary_text) return null;
|
|
42
|
+
const contentHash = summary.content_hash;
|
|
43
|
+
if (!contentHash) return null;
|
|
44
|
+
|
|
45
|
+
const existing = this._mounted.get(contentHash);
|
|
46
|
+
if (existing && existing.isConnected) {
|
|
47
|
+
const textEl = existing.querySelector('.hunk-summary-text');
|
|
48
|
+
if (textEl) textEl.textContent = summary.summary_text;
|
|
49
|
+
return existing;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const row = document.createElement('tr');
|
|
53
|
+
row.className = 'hunk-summary-row';
|
|
54
|
+
row.dataset.contentHash = contentHash;
|
|
55
|
+
|
|
56
|
+
const cell = document.createElement('td');
|
|
57
|
+
cell.colSpan = 2;
|
|
58
|
+
cell.className = 'hunk-summary-cell';
|
|
59
|
+
|
|
60
|
+
const annotation = document.createElement('div');
|
|
61
|
+
annotation.className = 'hunk-summary-annotation';
|
|
62
|
+
|
|
63
|
+
const icon = document.createElement('span');
|
|
64
|
+
icon.className = 'hunk-summary-icon';
|
|
65
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
66
|
+
icon.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">' +
|
|
67
|
+
'<path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>' +
|
|
68
|
+
'</svg>';
|
|
69
|
+
|
|
70
|
+
const text = document.createElement('span');
|
|
71
|
+
text.className = 'hunk-summary-text';
|
|
72
|
+
text.textContent = summary.summary_text;
|
|
73
|
+
|
|
74
|
+
annotation.appendChild(icon);
|
|
75
|
+
annotation.appendChild(text);
|
|
76
|
+
cell.appendChild(annotation);
|
|
77
|
+
row.appendChild(cell);
|
|
78
|
+
|
|
79
|
+
// Insert IMMEDIATELY ABOVE the hunk's first code line so a reader
|
|
80
|
+
// scrolling down sees the description before the change itself.
|
|
81
|
+
anchorRow.parentNode.insertBefore(row, anchorRow);
|
|
82
|
+
this._mounted.set(contentHash, row);
|
|
83
|
+
return row;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remove a mounted annotation by its content hash.
|
|
88
|
+
* @param {string} contentHash
|
|
89
|
+
* @returns {boolean} true if a row was found and removed
|
|
90
|
+
*/
|
|
91
|
+
removeByHash(contentHash) {
|
|
92
|
+
const row = this._mounted.get(contentHash);
|
|
93
|
+
if (!row) return false;
|
|
94
|
+
if (row.isConnected) row.remove();
|
|
95
|
+
this._mounted.delete(contentHash);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Clear all mounted annotations (e.g. before re-rendering the diff).
|
|
101
|
+
*/
|
|
102
|
+
reset() {
|
|
103
|
+
for (const row of this._mounted.values()) {
|
|
104
|
+
if (row.isConnected) row.remove();
|
|
105
|
+
}
|
|
106
|
+
this._mounted.clear();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof window !== 'undefined') {
|
|
111
|
+
window.HunkSummaryRenderer = HunkSummaryRenderer;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
115
|
+
module.exports = { HunkSummaryRenderer };
|
|
116
|
+
}
|
|
@@ -21,6 +21,11 @@ function cleanupLegacyLocalStorage() {
|
|
|
21
21
|
'settingsReferrer', // Unscoped version (now uses settingsReferrer:${repo})
|
|
22
22
|
];
|
|
23
23
|
|
|
24
|
+
// Legacy key prefixes (one entry per review id) that we need to sweep
|
|
25
|
+
const legacyPrefixes = [
|
|
26
|
+
'pair-review:dismissed-summaries:' // Replaced by per-file toggle in v3.4
|
|
27
|
+
];
|
|
28
|
+
|
|
24
29
|
// Remove known legacy keys
|
|
25
30
|
legacyKeys.forEach(key => {
|
|
26
31
|
if (localStorage.getItem(key) !== null) {
|
|
@@ -28,6 +33,17 @@ function cleanupLegacyLocalStorage() {
|
|
|
28
33
|
console.log(`[cleanup] Removed legacy localStorage key: ${key}`);
|
|
29
34
|
}
|
|
30
35
|
});
|
|
36
|
+
|
|
37
|
+
// Sweep prefixed keys. localStorage.length and key(i) iteration in
|
|
38
|
+
// reverse order so removals don't shift indexes we still need to read.
|
|
39
|
+
for (let i = localStorage.length - 1; i >= 0; i--) {
|
|
40
|
+
const key = localStorage.key(i);
|
|
41
|
+
if (!key) continue;
|
|
42
|
+
if (legacyPrefixes.some(prefix => key.startsWith(prefix))) {
|
|
43
|
+
localStorage.removeItem(key);
|
|
44
|
+
console.log(`[cleanup] Removed legacy localStorage key: ${key}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
// Export for use in other modules
|