@in-the-loop-labs/pair-review 2.0.0 → 2.0.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 +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 +76 -44
- package/public/js/components/ChatPanel.js +75 -72
- package/public/js/modules/comment-manager.js +46 -1
- package/public/js/pr.js +54 -9
- package/src/ai/cursor-agent-provider.js +9 -4
- package/src/chat/prompt-builder.js +11 -1
- package/src/routes/context-files.js +51 -0
- package/src/routes/pr.js +14 -6
- package/src/utils/diff-annotator.js +75 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.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": "2.0.
|
|
3
|
+
"version": "2.0.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/css/pr.css
CHANGED
|
@@ -11198,14 +11198,78 @@ body.resizing * {
|
|
|
11198
11198
|
opacity: 0.6;
|
|
11199
11199
|
}
|
|
11200
11200
|
|
|
11201
|
-
/*
|
|
11202
|
-
|
|
11203
|
-
|
|
11201
|
+
/* Unified line highlight for navigating to lines from chat/context links.
|
|
11202
|
+
Applied to <tr> elements; styles target <td> children since box-shadow
|
|
11203
|
+
and background-color animations don't render on <tr> in most browsers. */
|
|
11204
|
+
.chat-line-highlight td {
|
|
11205
|
+
animation: chat-line-highlight-bg 3.5s ease-out forwards !important;
|
|
11204
11206
|
}
|
|
11205
11207
|
|
|
11206
|
-
|
|
11207
|
-
|
|
11208
|
-
|
|
11208
|
+
.chat-line-highlight > td:first-child {
|
|
11209
|
+
animation: chat-line-highlight-border 3.5s ease-out forwards !important;
|
|
11210
|
+
}
|
|
11211
|
+
|
|
11212
|
+
@keyframes chat-line-highlight-bg {
|
|
11213
|
+
0% {
|
|
11214
|
+
background-color: rgba(227, 179, 65, 0.20);
|
|
11215
|
+
}
|
|
11216
|
+
57% {
|
|
11217
|
+
background-color: rgba(227, 179, 65, 0.08);
|
|
11218
|
+
}
|
|
11219
|
+
100% {
|
|
11220
|
+
background-color: transparent;
|
|
11221
|
+
}
|
|
11222
|
+
}
|
|
11223
|
+
|
|
11224
|
+
@keyframes chat-line-highlight-border {
|
|
11225
|
+
0% {
|
|
11226
|
+
box-shadow: inset 5px 0 0 #e3b341;
|
|
11227
|
+
background-color: rgba(227, 179, 65, 0.20);
|
|
11228
|
+
}
|
|
11229
|
+
57% {
|
|
11230
|
+
/* ~2s: border still solid, background fading */
|
|
11231
|
+
box-shadow: inset 5px 0 0 #e3b341;
|
|
11232
|
+
background-color: rgba(227, 179, 65, 0.08);
|
|
11233
|
+
}
|
|
11234
|
+
100% {
|
|
11235
|
+
box-shadow: inset 5px 0 0 transparent;
|
|
11236
|
+
background-color: transparent;
|
|
11237
|
+
}
|
|
11238
|
+
}
|
|
11239
|
+
|
|
11240
|
+
[data-theme="dark"] .chat-line-highlight td {
|
|
11241
|
+
animation: chat-line-highlight-bg-dark 3.5s ease-out forwards !important;
|
|
11242
|
+
}
|
|
11243
|
+
|
|
11244
|
+
[data-theme="dark"] .chat-line-highlight > td:first-child {
|
|
11245
|
+
animation: chat-line-highlight-border-dark 3.5s ease-out forwards !important;
|
|
11246
|
+
}
|
|
11247
|
+
|
|
11248
|
+
@keyframes chat-line-highlight-bg-dark {
|
|
11249
|
+
0% {
|
|
11250
|
+
background-color: rgba(227, 179, 65, 0.15);
|
|
11251
|
+
}
|
|
11252
|
+
57% {
|
|
11253
|
+
background-color: rgba(227, 179, 65, 0.06);
|
|
11254
|
+
}
|
|
11255
|
+
100% {
|
|
11256
|
+
background-color: transparent;
|
|
11257
|
+
}
|
|
11258
|
+
}
|
|
11259
|
+
|
|
11260
|
+
@keyframes chat-line-highlight-border-dark {
|
|
11261
|
+
0% {
|
|
11262
|
+
box-shadow: inset 5px 0 0 #e3b341;
|
|
11263
|
+
background-color: rgba(227, 179, 65, 0.15);
|
|
11264
|
+
}
|
|
11265
|
+
57% {
|
|
11266
|
+
box-shadow: inset 5px 0 0 #e3b341;
|
|
11267
|
+
background-color: rgba(227, 179, 65, 0.06);
|
|
11268
|
+
}
|
|
11269
|
+
100% {
|
|
11270
|
+
box-shadow: inset 5px 0 0 transparent;
|
|
11271
|
+
background-color: transparent;
|
|
11272
|
+
}
|
|
11209
11273
|
}
|
|
11210
11274
|
|
|
11211
11275
|
.chat-file-link--loading {
|
|
@@ -11227,28 +11291,6 @@ body.resizing * {
|
|
|
11227
11291
|
to { transform: rotate(360deg); }
|
|
11228
11292
|
}
|
|
11229
11293
|
|
|
11230
|
-
/* Gutter arrow for "already here" micro-feedback */
|
|
11231
|
-
.chat-gutter-arrow {
|
|
11232
|
-
position: absolute;
|
|
11233
|
-
left: -2px;
|
|
11234
|
-
top: 50%;
|
|
11235
|
-
transform: translateY(-50%);
|
|
11236
|
-
color: #e3b341;
|
|
11237
|
-
font-size: 20px;
|
|
11238
|
-
font-weight: bold;
|
|
11239
|
-
pointer-events: none;
|
|
11240
|
-
transition: opacity 0.3s ease;
|
|
11241
|
-
z-index: 2;
|
|
11242
|
-
}
|
|
11243
|
-
|
|
11244
|
-
.chat-gutter-arrow--fade {
|
|
11245
|
-
opacity: 0;
|
|
11246
|
-
}
|
|
11247
|
-
|
|
11248
|
-
[data-theme="dark"] .chat-gutter-arrow {
|
|
11249
|
-
color: #e3b341;
|
|
11250
|
-
}
|
|
11251
|
-
|
|
11252
11294
|
/* Error bubble */
|
|
11253
11295
|
.chat-panel__error-bubble {
|
|
11254
11296
|
display: flex;
|
|
@@ -11362,6 +11404,7 @@ body.resizing * {
|
|
|
11362
11404
|
|
|
11363
11405
|
/* Chat Panel Context Card */
|
|
11364
11406
|
.chat-panel__context-card {
|
|
11407
|
+
position: relative;
|
|
11365
11408
|
display: flex;
|
|
11366
11409
|
align-items: center;
|
|
11367
11410
|
gap: 6px;
|
|
@@ -11391,6 +11434,7 @@ body.resizing * {
|
|
|
11391
11434
|
overflow: hidden;
|
|
11392
11435
|
text-overflow: ellipsis;
|
|
11393
11436
|
white-space: nowrap;
|
|
11437
|
+
min-width: 0;
|
|
11394
11438
|
color: var(--color-text-secondary);
|
|
11395
11439
|
}
|
|
11396
11440
|
|
|
@@ -11405,9 +11449,12 @@ body.resizing * {
|
|
|
11405
11449
|
display: none;
|
|
11406
11450
|
align-items: center;
|
|
11407
11451
|
justify-content: center;
|
|
11452
|
+
position: absolute;
|
|
11453
|
+
right: 6px;
|
|
11454
|
+
top: 50%;
|
|
11455
|
+
transform: translateY(-50%);
|
|
11408
11456
|
width: 16px;
|
|
11409
11457
|
height: 16px;
|
|
11410
|
-
margin-left: auto;
|
|
11411
11458
|
padding: 0;
|
|
11412
11459
|
border: none;
|
|
11413
11460
|
border-radius: 50%;
|
|
@@ -11416,7 +11463,6 @@ body.resizing * {
|
|
|
11416
11463
|
font-size: 12px;
|
|
11417
11464
|
line-height: 1;
|
|
11418
11465
|
cursor: pointer;
|
|
11419
|
-
flex-shrink: 0;
|
|
11420
11466
|
transition: background 0.15s ease, color 0.15s ease;
|
|
11421
11467
|
}
|
|
11422
11468
|
|
|
@@ -11938,20 +11984,6 @@ body.resizing * {
|
|
|
11938
11984
|
color: var(--color-text-secondary);
|
|
11939
11985
|
}
|
|
11940
11986
|
|
|
11941
|
-
/* Highlight flash animation for scroll-to-line targeting */
|
|
11942
|
-
.highlight-flash {
|
|
11943
|
-
animation: highlightFlash 1.5s ease-out;
|
|
11944
|
-
}
|
|
11945
|
-
|
|
11946
|
-
@keyframes highlightFlash {
|
|
11947
|
-
0% {
|
|
11948
|
-
background-color: var(--color-selection, #fff8c5);
|
|
11949
|
-
}
|
|
11950
|
-
100% {
|
|
11951
|
-
background-color: transparent;
|
|
11952
|
-
}
|
|
11953
|
-
}
|
|
11954
|
-
|
|
11955
11987
|
/* Dark theme overrides for context files */
|
|
11956
11988
|
[data-theme="dark"] .d2h-file-wrapper.context-file {
|
|
11957
11989
|
border-left-color: var(--color-accent-primary, #58a6ff);
|
|
@@ -2135,6 +2135,7 @@ class ChatPanel {
|
|
|
2135
2135
|
const bubble = streamingMsg.querySelector('.chat-panel__bubble');
|
|
2136
2136
|
if (bubble) {
|
|
2137
2137
|
bubble.innerHTML = this.renderMarkdown(text) + '<span class="chat-panel__cursor"></span>';
|
|
2138
|
+
this._linkifyFileReferences(bubble);
|
|
2138
2139
|
}
|
|
2139
2140
|
this.scrollToBottom();
|
|
2140
2141
|
}
|
|
@@ -2530,6 +2531,7 @@ class ChatPanel {
|
|
|
2530
2531
|
if (contextEl) {
|
|
2531
2532
|
// Context file — find the right chunk by line number or use first chunk
|
|
2532
2533
|
let contextFileId = contextEl.dataset?.contextId; // legacy: on wrapper itself
|
|
2534
|
+
let lineFoundInChunk = !!contextFileId; // legacy mode assumes line is present
|
|
2533
2535
|
if (!contextFileId && lineStart) {
|
|
2534
2536
|
// Merged wrapper: find chunk tbody containing this line
|
|
2535
2537
|
const chunks = [...contextEl.querySelectorAll('tbody.context-chunk[data-context-id]')];
|
|
@@ -2537,27 +2539,32 @@ class ChatPanel {
|
|
|
2537
2539
|
const row = chunk.querySelector(`tr[data-line-number="${lineStart}"]`);
|
|
2538
2540
|
if (row) {
|
|
2539
2541
|
contextFileId = chunk.dataset.contextId;
|
|
2542
|
+
lineFoundInChunk = true;
|
|
2540
2543
|
break;
|
|
2541
2544
|
}
|
|
2542
2545
|
}
|
|
2543
2546
|
}
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
window.prManager
|
|
2547
|
+
|
|
2548
|
+
if (lineFoundInChunk || !lineStart) {
|
|
2549
|
+
if (!contextFileId) {
|
|
2550
|
+
const firstChunk = contextEl.querySelector('tbody.context-chunk[data-context-id]');
|
|
2551
|
+
if (firstChunk) contextFileId = firstChunk.dataset.contextId;
|
|
2552
|
+
}
|
|
2553
|
+
if (window.prManager?.scrollToContextFile) {
|
|
2554
|
+
window.prManager.scrollToContextFile(file, lineStart, contextFileId);
|
|
2555
|
+
}
|
|
2556
|
+
return;
|
|
2551
2557
|
}
|
|
2558
|
+
// Line not found in any existing chunk — fall through to add new range
|
|
2552
2559
|
} else {
|
|
2553
2560
|
// Diff file
|
|
2554
2561
|
if (lineStart) {
|
|
2555
|
-
this._scrollToLine(file, lineStart);
|
|
2562
|
+
await this._scrollToLine(file, lineStart, lineEnd);
|
|
2556
2563
|
} else if (window.prManager?.scrollToFile) {
|
|
2557
2564
|
window.prManager.scrollToFile(file);
|
|
2558
2565
|
}
|
|
2566
|
+
return;
|
|
2559
2567
|
}
|
|
2560
|
-
return;
|
|
2561
2568
|
}
|
|
2562
2569
|
|
|
2563
2570
|
// File not in DOM — try to add as context file
|
|
@@ -2574,7 +2581,7 @@ class ChatPanel {
|
|
|
2574
2581
|
|
|
2575
2582
|
if (result.type === 'diff') {
|
|
2576
2583
|
if (lineStart) {
|
|
2577
|
-
this._scrollToLine(file, lineStart);
|
|
2584
|
+
await this._scrollToLine(file, lineStart, lineEnd);
|
|
2578
2585
|
} else if (window.prManager?.scrollToFile) {
|
|
2579
2586
|
window.prManager.scrollToFile(file);
|
|
2580
2587
|
}
|
|
@@ -2594,13 +2601,17 @@ class ChatPanel {
|
|
|
2594
2601
|
}
|
|
2595
2602
|
|
|
2596
2603
|
/**
|
|
2597
|
-
* Scroll to a specific line within a file wrapper,
|
|
2598
|
-
*
|
|
2604
|
+
* Scroll to a specific line within a file wrapper, applying a bold
|
|
2605
|
+
* left-border + background highlight that fades over ~3.5s.
|
|
2606
|
+
* Supports line ranges: if lineEnd is provided, all rows from
|
|
2607
|
+
* lineStart to lineEnd are highlighted. If the line is in a collapsed
|
|
2608
|
+
* diff chunk, expands the chunk first via ensureLinesVisible().
|
|
2599
2609
|
* @param {string} file - File path
|
|
2600
|
-
* @param {number} lineStart - Target line number
|
|
2610
|
+
* @param {number} lineStart - Target line number (start of range)
|
|
2611
|
+
* @param {number|null} [lineEnd] - End of target line range (used for expansion)
|
|
2601
2612
|
* @param {HTMLElement} [fileWrapper] - Pre-resolved file wrapper element
|
|
2602
2613
|
*/
|
|
2603
|
-
_scrollToLine(file, lineStart, fileWrapper) {
|
|
2614
|
+
async _scrollToLine(file, lineStart, lineEnd, fileWrapper) {
|
|
2604
2615
|
if (!fileWrapper) {
|
|
2605
2616
|
const escaped = CSS.escape(file);
|
|
2606
2617
|
fileWrapper = document.querySelector(`[data-file-name="${escaped}"]`) ||
|
|
@@ -2608,68 +2619,60 @@ class ChatPanel {
|
|
|
2608
2619
|
}
|
|
2609
2620
|
if (!fileWrapper) return;
|
|
2610
2621
|
|
|
2611
|
-
//
|
|
2612
|
-
const
|
|
2613
|
-
let
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2622
|
+
// Collect all target rows (single line or range)
|
|
2623
|
+
const end = lineEnd || lineStart;
|
|
2624
|
+
let targetRows = this._findLineRows(fileWrapper, lineStart, end);
|
|
2625
|
+
|
|
2626
|
+
// If not found, try expanding the collapsed diff context
|
|
2627
|
+
if (targetRows.length === 0 && window.prManager?.ensureLinesVisible) {
|
|
2628
|
+
await window.prManager.ensureLinesVisible([
|
|
2629
|
+
{ file, line_start: lineStart, line_end: end, side: 'RIGHT' }
|
|
2630
|
+
]);
|
|
2631
|
+
targetRows = this._findLineRows(fileWrapper, lineStart, end);
|
|
2619
2632
|
}
|
|
2620
|
-
if (
|
|
2633
|
+
if (targetRows.length === 0) return;
|
|
2634
|
+
|
|
2635
|
+
const primaryRow = targetRows[0];
|
|
2621
2636
|
|
|
2622
|
-
// Check if the target row is already visible in the viewport
|
|
2623
|
-
const rect =
|
|
2637
|
+
// Check if the primary target row is already visible in the viewport
|
|
2638
|
+
const rect = primaryRow.getBoundingClientRect();
|
|
2624
2639
|
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
|
|
2625
2640
|
|
|
2626
|
-
if (isVisible) {
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
setTimeout(() => arrow.remove(), 500);
|
|
2662
|
-
}, 1500);
|
|
2663
|
-
}
|
|
2664
|
-
|
|
2665
|
-
// Hop animation — small vertical nudge
|
|
2666
|
-
const scrollContainer = document.getElementById('diff-container') ||
|
|
2667
|
-
row.closest('.d2h-wrapper') || document.documentElement;
|
|
2668
|
-
const currentScroll = scrollContainer.scrollTop;
|
|
2669
|
-
scrollContainer.scrollTo({ top: currentScroll - 30, behavior: 'smooth' });
|
|
2670
|
-
setTimeout(() => {
|
|
2671
|
-
scrollContainer.scrollTo({ top: currentScroll, behavior: 'smooth' });
|
|
2672
|
-
}, 150);
|
|
2641
|
+
if (!isVisible) {
|
|
2642
|
+
primaryRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
// Apply the highlight to all target rows
|
|
2646
|
+
for (const row of targetRows) {
|
|
2647
|
+
// Remove any existing highlight first (in case of rapid re-clicks)
|
|
2648
|
+
row.classList.remove('chat-line-highlight');
|
|
2649
|
+
// Force reflow so re-adding the class restarts the animation
|
|
2650
|
+
void row.offsetWidth;
|
|
2651
|
+
row.classList.add('chat-line-highlight');
|
|
2652
|
+
row.addEventListener('animationend', () => {
|
|
2653
|
+
row.classList.remove('chat-line-highlight');
|
|
2654
|
+
}, { once: true });
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
/**
|
|
2659
|
+
* Find all table rows matching a line range within a file wrapper.
|
|
2660
|
+
* @param {HTMLElement} fileWrapper - The file wrapper element
|
|
2661
|
+
* @param {number} lineStart - Start line number
|
|
2662
|
+
* @param {number} lineEnd - End line number (inclusive)
|
|
2663
|
+
* @returns {HTMLElement[]} Matching rows
|
|
2664
|
+
*/
|
|
2665
|
+
_findLineRows(fileWrapper, lineStart, lineEnd) {
|
|
2666
|
+
const rows = [];
|
|
2667
|
+
const lineNums = fileWrapper.querySelectorAll('.line-num2');
|
|
2668
|
+
for (const ln of lineNums) {
|
|
2669
|
+
const num = parseInt(ln.textContent.trim(), 10);
|
|
2670
|
+
if (!isNaN(num) && num >= lineStart && num <= lineEnd) {
|
|
2671
|
+
const row = ln.closest('tr');
|
|
2672
|
+
if (row) rows.push(row);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
return rows;
|
|
2673
2676
|
}
|
|
2674
2677
|
|
|
2675
2678
|
/**
|
|
@@ -43,6 +43,45 @@ class CommentManager {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Check whether a line falls within a diff hunk for the given file.
|
|
48
|
+
* Uses the parsed hunk blocks from HunkParser rather than relying on
|
|
49
|
+
* diff_position, which may be absent for comments created by the chat agent.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} fileName - The file path
|
|
52
|
+
* @param {number} lineNum - The line number to check
|
|
53
|
+
* @param {string} [side='RIGHT'] - 'LEFT' for old/deleted lines, 'RIGHT' for new/added/context
|
|
54
|
+
* @returns {boolean} true if the line is inside a diff hunk
|
|
55
|
+
*/
|
|
56
|
+
isLineInDiffHunk(fileName, lineNum, side = 'RIGHT') {
|
|
57
|
+
const patch = this.prManager?.filePatches?.get(fileName);
|
|
58
|
+
if (!patch || !window.HunkParser) return false;
|
|
59
|
+
|
|
60
|
+
const blocks = window.HunkParser.parseDiffIntoBlocks(patch);
|
|
61
|
+
for (const block of blocks) {
|
|
62
|
+
let oldLine = block.oldStart;
|
|
63
|
+
let newLine = block.newStart;
|
|
64
|
+
|
|
65
|
+
for (const line of block.lines) {
|
|
66
|
+
if (line.startsWith('\\ No newline')) continue;
|
|
67
|
+
if (line.startsWith('+')) {
|
|
68
|
+
if (side === 'RIGHT' && newLine === lineNum) return true;
|
|
69
|
+
newLine++;
|
|
70
|
+
} else if (line.startsWith('-')) {
|
|
71
|
+
if (side === 'LEFT' && oldLine === lineNum) return true;
|
|
72
|
+
oldLine++;
|
|
73
|
+
} else {
|
|
74
|
+
// Context line — present on both sides
|
|
75
|
+
if (side === 'LEFT' && oldLine === lineNum) return true;
|
|
76
|
+
if (side === 'RIGHT' && newLine === lineNum) return true;
|
|
77
|
+
oldLine++;
|
|
78
|
+
newLine++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
46
85
|
/**
|
|
47
86
|
* Show comment form inline
|
|
48
87
|
* @param {HTMLElement} targetRow - The row to insert the comment form after
|
|
@@ -503,7 +542,13 @@ class CommentManager {
|
|
|
503
542
|
// WORKAROUND: Comments on expanded context lines (outside diff hunks) will be
|
|
504
543
|
// submitted as file-level comments since GitHub's API doesn't support line-level
|
|
505
544
|
// comments on these lines. Show an indicator to inform the user.
|
|
506
|
-
|
|
545
|
+
// Check actual diff hunk membership rather than diff_position, which may be
|
|
546
|
+
// absent for comments created by the chat agent even when they target hunk lines.
|
|
547
|
+
const commentSide = comment.side || 'RIGHT';
|
|
548
|
+
const isRange = comment.line_end && comment.line_end !== comment.line_start;
|
|
549
|
+
const isExpandedContext = isRange
|
|
550
|
+
? !this.isLineInDiffHunk(comment.file, comment.line_start, commentSide) || !this.isLineInDiffHunk(comment.file, comment.line_end, commentSide)
|
|
551
|
+
: !this.isLineInDiffHunk(comment.file, comment.line_start, commentSide);
|
|
507
552
|
const expandedContextIndicator = isExpandedContext
|
|
508
553
|
? `<span class="expanded-context-indicator" title="This expanded context comment will be posted to GitHub as a file-level comment">
|
|
509
554
|
<svg viewBox="0 0 16 16" width="14" height="14">
|
package/public/js/pr.js
CHANGED
|
@@ -4575,9 +4575,11 @@ class PRManager {
|
|
|
4575
4575
|
setTimeout(() => {
|
|
4576
4576
|
const row = wrapper.querySelector(`tr[data-line-number="${lineStart}"]`);
|
|
4577
4577
|
if (row) {
|
|
4578
|
-
row.classList.
|
|
4578
|
+
row.classList.remove('chat-line-highlight');
|
|
4579
|
+
void row.offsetWidth;
|
|
4580
|
+
row.classList.add('chat-line-highlight');
|
|
4579
4581
|
row.addEventListener('animationend', () => {
|
|
4580
|
-
row.classList.remove('highlight
|
|
4582
|
+
row.classList.remove('chat-line-highlight');
|
|
4581
4583
|
}, { once: true });
|
|
4582
4584
|
}
|
|
4583
4585
|
}, 400);
|
|
@@ -4593,13 +4595,7 @@ class PRManager {
|
|
|
4593
4595
|
return { type: 'diff' };
|
|
4594
4596
|
}
|
|
4595
4597
|
|
|
4596
|
-
// 3.
|
|
4597
|
-
const existing = this.contextFiles?.find(cf => cf.file === file);
|
|
4598
|
-
if (existing) {
|
|
4599
|
-
return { type: 'context', contextFile: existing };
|
|
4600
|
-
}
|
|
4601
|
-
|
|
4602
|
-
// 4. Compute line range defaults
|
|
4598
|
+
// 3. Compute line range values up front (used by both existing-check and POST)
|
|
4603
4599
|
let lineStartVal, lineEndVal;
|
|
4604
4600
|
if (lineStart == null && lineEnd == null) {
|
|
4605
4601
|
lineStartVal = 1;
|
|
@@ -4612,6 +4608,55 @@ class PRManager {
|
|
|
4612
4608
|
lineEndVal = Math.min(lineEnd, lineStart + 499);
|
|
4613
4609
|
}
|
|
4614
4610
|
|
|
4611
|
+
// 4. Check existing context files — expand range if needed
|
|
4612
|
+
const existingEntries = this.contextFiles?.filter(cf => cf.file === file) || [];
|
|
4613
|
+
if (existingEntries.length > 0 && lineStart != null) {
|
|
4614
|
+
const covering = existingEntries.find(cf =>
|
|
4615
|
+
cf.line_start <= lineStartVal && cf.line_end >= lineEndVal
|
|
4616
|
+
);
|
|
4617
|
+
if (covering) {
|
|
4618
|
+
return { type: 'context', contextFile: covering };
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
const overlapping = existingEntries.find(cf =>
|
|
4622
|
+
cf.line_start <= lineEndVal && cf.line_end >= lineStartVal
|
|
4623
|
+
);
|
|
4624
|
+
if (overlapping) {
|
|
4625
|
+
const newStart = Math.min(overlapping.line_start, lineStartVal);
|
|
4626
|
+
let newEnd = Math.max(overlapping.line_end, lineEndVal);
|
|
4627
|
+
if (newEnd - newStart + 1 > 500) {
|
|
4628
|
+
newEnd = newStart + 499;
|
|
4629
|
+
}
|
|
4630
|
+
const reviewId = this.currentPR.id;
|
|
4631
|
+
try {
|
|
4632
|
+
const resp = await fetch(`/api/reviews/${reviewId}/context-files/${overlapping.id}`, {
|
|
4633
|
+
method: 'PATCH',
|
|
4634
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4635
|
+
body: JSON.stringify({ line_start: newStart, line_end: newEnd })
|
|
4636
|
+
});
|
|
4637
|
+
if (resp.ok) {
|
|
4638
|
+
// Evict stale entries for this file so loadContextFiles sees
|
|
4639
|
+
// them as new IDs and triggers a fresh render.
|
|
4640
|
+
const staleFile = overlapping.file;
|
|
4641
|
+
this.contextFiles = (this.contextFiles || []).filter(cf => cf.file !== staleFile);
|
|
4642
|
+
// Remove the file wrapper from the DOM so chunks are re-created
|
|
4643
|
+
const staleWrapper = document.querySelector(
|
|
4644
|
+
`.d2h-file-wrapper.context-file[data-file-name="${CSS.escape(staleFile)}"]`
|
|
4645
|
+
);
|
|
4646
|
+
if (staleWrapper) staleWrapper.remove();
|
|
4647
|
+
|
|
4648
|
+
await this.loadContextFiles();
|
|
4649
|
+
const updated = this.contextFiles?.find(cf => cf.id === overlapping.id);
|
|
4650
|
+
return { type: 'context', contextFile: updated || overlapping, expanded: true };
|
|
4651
|
+
}
|
|
4652
|
+
} catch (err) {
|
|
4653
|
+
console.error('Error expanding context file range:', err);
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4656
|
+
} else if (existingEntries.length > 0) {
|
|
4657
|
+
return { type: 'context', contextFile: existingEntries[0] };
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4615
4660
|
// 5. POST to add context file
|
|
4616
4661
|
const reviewId = this.currentPR.id;
|
|
4617
4662
|
try {
|
|
@@ -165,6 +165,7 @@ class CursorAgentProvider extends AIProvider {
|
|
|
165
165
|
* @param {string[]} configOverrides.extra_args - Additional CLI arguments
|
|
166
166
|
* @param {Object} configOverrides.env - Additional environment variables
|
|
167
167
|
* @param {Object[]} configOverrides.models - Custom model definitions
|
|
168
|
+
* @param {boolean} configOverrides.yolo - When true, use --yolo instead of --trust/--sandbox
|
|
168
169
|
*/
|
|
169
170
|
constructor(model = DEFAULT_CURSOR_AGENT_MODEL, configOverrides = {}) {
|
|
170
171
|
super(model);
|
|
@@ -209,9 +210,12 @@ class CursorAgentProvider extends AIProvider {
|
|
|
209
210
|
// in parseCursorAgentResponse (isStreamingDelta / timestamp_ms check). If this
|
|
210
211
|
// flag is removed, the parser will treat all assistant events as complete messages,
|
|
211
212
|
// which is fine but will change accumulation behavior. Keep these in sync.
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
const
|
|
213
|
+
// Normal mode: --trust to auto-approve the working directory, --sandbox enabled for safety
|
|
214
|
+
// Yolo mode: --yolo to skip all permissions (implies trust + sandbox disabled)
|
|
215
|
+
const permissionArgs = configOverrides.yolo
|
|
216
|
+
? ['--yolo']
|
|
217
|
+
: ['--trust', '--sandbox', 'enabled'];
|
|
218
|
+
const baseArgs = ['-p', '--output-format', 'stream-json', '--stream-partial-output', '--model', model, ...permissionArgs];
|
|
215
219
|
const providerArgs = configOverrides.extra_args || [];
|
|
216
220
|
const modelConfig = configOverrides.models?.find(m => m.id === model);
|
|
217
221
|
const modelArgs = modelConfig?.extra_args || [];
|
|
@@ -700,7 +704,8 @@ class CursorAgentProvider extends AIProvider {
|
|
|
700
704
|
*/
|
|
701
705
|
buildArgsForModel(model) {
|
|
702
706
|
// Base args for extraction (text output, no tools needed)
|
|
703
|
-
|
|
707
|
+
// --trust needed to auto-approve the working directory without interactive prompt
|
|
708
|
+
const baseArgs = ['-p', '--output-format', 'text', '--trust', '--model', model];
|
|
704
709
|
// Provider-level extra_args (from configOverrides)
|
|
705
710
|
const providerArgs = this.configOverrides?.extra_args || [];
|
|
706
711
|
// Model-specific extra_args (from the model config for the given model)
|
|
@@ -57,7 +57,7 @@ function buildChatPrompt({ review, prData, skillPath, chatInstructions }) {
|
|
|
57
57
|
? `(\`${skillPath}\`)`
|
|
58
58
|
: '(`.pi/skills/pair-review-api/SKILL.md`)';
|
|
59
59
|
sections.push(
|
|
60
|
-
`You MUST
|
|
60
|
+
`You MUST read the pair-review-api skill ${skillRef} for endpoint details using the Read tool. With it you can create, update, and delete review comments, adopt or dismiss AI suggestions, and trigger new analyses via curl.\n` +
|
|
61
61
|
'IMPORTANT: Do NOT mention that you are reading a skill file, loading API documentation, or consulting reference material. Just use the API naturally as if you already know it.'
|
|
62
62
|
);
|
|
63
63
|
|
|
@@ -72,6 +72,16 @@ function buildChatPrompt({ review, prData, skillPath, chatInstructions }) {
|
|
|
72
72
|
'Add context files judiciously — only when directly relevant, with focused line ranges.'
|
|
73
73
|
);
|
|
74
74
|
|
|
75
|
+
// Tool usage discipline — avoid unnecessary Task delegation
|
|
76
|
+
sections.push(
|
|
77
|
+
'## Tool usage\n\n' +
|
|
78
|
+
'Prefer answering from context you already have (the diff, suggestions, and prior conversation). ' +
|
|
79
|
+
'For simple lookups — reading a file, searching for a symbol, running a git command — use the basic tools directly. ' +
|
|
80
|
+
'Only use the Task tool for genuinely large, multi-step operations that would consume significant context ' +
|
|
81
|
+
'(e.g., tracing a complex call chain across many files, or broad codebase exploration). ' +
|
|
82
|
+
'Most chat questions should not require a Task.'
|
|
83
|
+
);
|
|
84
|
+
|
|
75
85
|
// Instructions
|
|
76
86
|
sections.push(
|
|
77
87
|
'Answer questions about this review, the code changes, and any AI suggestions. ' +
|
|
@@ -172,6 +172,57 @@ router.get('/api/reviews/:reviewId/context-files', validateReviewId, async (req,
|
|
|
172
172
|
}
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* PATCH /api/reviews/:reviewId/context-files/:id
|
|
177
|
+
* Update the line range of an existing context file entry.
|
|
178
|
+
* Body: { line_start, line_end }
|
|
179
|
+
*/
|
|
180
|
+
router.patch('/api/reviews/:reviewId/context-files/:id', validateReviewId, async (req, res) => {
|
|
181
|
+
try {
|
|
182
|
+
const id = parseInt(req.params.id, 10);
|
|
183
|
+
|
|
184
|
+
if (isNaN(id) || id <= 0) {
|
|
185
|
+
return res.status(400).json({ error: 'Invalid context file ID' });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { line_start, line_end } = req.body;
|
|
189
|
+
|
|
190
|
+
const lineStart = parseInt(line_start, 10);
|
|
191
|
+
const lineEnd = parseInt(line_end, 10);
|
|
192
|
+
|
|
193
|
+
if (isNaN(lineStart) || lineStart <= 0) {
|
|
194
|
+
return res.status(400).json({ error: 'line_start must be a positive integer' });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (isNaN(lineEnd) || lineEnd <= 0) {
|
|
198
|
+
return res.status(400).json({ error: 'line_end must be a positive integer' });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (lineEnd < lineStart) {
|
|
202
|
+
return res.status(400).json({ error: 'line_end must be >= line_start' });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (lineEnd - lineStart + 1 > 500) {
|
|
206
|
+
return res.status(400).json({ error: 'Range cannot exceed 500 lines' });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const db = req.app.get('db');
|
|
210
|
+
const contextFileRepo = new ContextFileRepository(db);
|
|
211
|
+
|
|
212
|
+
const updated = await contextFileRepo.updateRange(id, lineStart, lineEnd);
|
|
213
|
+
|
|
214
|
+
if (!updated) {
|
|
215
|
+
return res.status(404).json({ error: 'Context file not found' });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
res.json({ success: true });
|
|
219
|
+
broadcastReviewEvent(req.reviewId, { type: 'review:context_files_changed' }, { sourceClientId: req.get('X-Client-Id') });
|
|
220
|
+
} catch (error) {
|
|
221
|
+
logger.error('Error updating context file range:', error);
|
|
222
|
+
res.status(500).json({ error: 'Failed to update context file range' });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
175
226
|
/**
|
|
176
227
|
* DELETE /api/reviews/:reviewId/context-files/:id
|
|
177
228
|
* Remove a single context file range by ID.
|
package/src/routes/pr.js
CHANGED
|
@@ -25,6 +25,7 @@ const { v4: uuidv4 } = require('uuid');
|
|
|
25
25
|
const fs = require('fs').promises;
|
|
26
26
|
const path = require('path');
|
|
27
27
|
const logger = require('../utils/logger');
|
|
28
|
+
const { buildDiffLineSet } = require('../utils/diff-annotator');
|
|
28
29
|
const { broadcastReviewEvent } = require('../sse/review-events');
|
|
29
30
|
const simpleGit = require('simple-git');
|
|
30
31
|
const {
|
|
@@ -988,8 +989,8 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
988
989
|
// GraphQL supports both line-level comments (within diff hunks) and file-level comments
|
|
989
990
|
// (for expanded context lines outside diff hunks via subjectType: FILE).
|
|
990
991
|
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
992
|
+
// We check whether the comment's target line actually appears in a diff hunk
|
|
993
|
+
// rather than relying on diff_position (which may not be set by all sources).
|
|
993
994
|
const prNodeId = prData.node_id;
|
|
994
995
|
if (!prNodeId) {
|
|
995
996
|
return res.status(400).json({
|
|
@@ -997,6 +998,8 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
997
998
|
});
|
|
998
999
|
}
|
|
999
1000
|
|
|
1001
|
+
const diffLineSet = buildDiffLineSet(diffContent);
|
|
1002
|
+
|
|
1000
1003
|
const graphqlComments = comments.map(comment => {
|
|
1001
1004
|
const side = comment.side || 'RIGHT';
|
|
1002
1005
|
const isRange = comment.line_end && comment.line_end !== comment.line_start;
|
|
@@ -1013,10 +1016,15 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1013
1016
|
};
|
|
1014
1017
|
}
|
|
1015
1018
|
|
|
1016
|
-
// Detect expanded context comments
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
|
|
1019
|
+
// Detect expanded context comments by checking whether the target line
|
|
1020
|
+
// actually appears in a diff hunk. This is more reliable than checking
|
|
1021
|
+
// diff_position, which may be absent for comments created by the chat agent.
|
|
1022
|
+
// For range comments, both endpoints must be inside the diff; if the start
|
|
1023
|
+
// line falls outside a hunk but the end is inside, submitting with start_line
|
|
1024
|
+
// would produce a position GitHub cannot render.
|
|
1025
|
+
const isExpandedContext = isRange
|
|
1026
|
+
? !diffLineSet.isLineInDiff(comment.file, comment.line_start, side) || !diffLineSet.isLineInDiff(comment.file, comment.line_end, side)
|
|
1027
|
+
: !diffLineSet.isLineInDiff(comment.file, comment.line_start, side);
|
|
1020
1028
|
|
|
1021
1029
|
if (isExpandedContext) {
|
|
1022
1030
|
// File-level comment with line reference prefix
|
|
@@ -187,6 +187,7 @@ function annotateDiff(rawDiff) {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
const lines = rawDiff.split('\n');
|
|
190
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
190
191
|
const output = [];
|
|
191
192
|
|
|
192
193
|
let currentFile = {};
|
|
@@ -404,11 +405,84 @@ function parseAnnotatedDiff(annotatedDiff) {
|
|
|
404
405
|
return files;
|
|
405
406
|
}
|
|
406
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Build a lookup of which file+side+line combinations appear in diff hunks.
|
|
410
|
+
* Used to determine whether a comment targets a line GitHub can render inline
|
|
411
|
+
* (inside a hunk) vs. one that must be submitted as file-level.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} rawDiff - Raw unified diff from git
|
|
414
|
+
* @returns {{ isLineInDiff: (file: string, line: number, side?: string) => boolean }}
|
|
415
|
+
*/
|
|
416
|
+
function buildDiffLineSet(rawDiff) {
|
|
417
|
+
if (!rawDiff || rawDiff.trim() === '') {
|
|
418
|
+
return { isLineInDiff: () => false };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const entries = new Set();
|
|
422
|
+
const diffLines = rawDiff.split('\n');
|
|
423
|
+
// Trim trailing empty element produced by split('\n') on newline-terminated input.
|
|
424
|
+
// Without this, the empty string matches the context-line branch and adds phantom
|
|
425
|
+
// entries for lines beyond the actual hunk boundary.
|
|
426
|
+
if (diffLines[diffLines.length - 1] === '') diffLines.pop();
|
|
427
|
+
let currentFile = {};
|
|
428
|
+
let oldLineNum = 0;
|
|
429
|
+
let newLineNum = 0;
|
|
430
|
+
let inHunk = false;
|
|
431
|
+
|
|
432
|
+
for (const line of diffLines) {
|
|
433
|
+
if (line.startsWith('diff --git')) {
|
|
434
|
+
currentFile = {};
|
|
435
|
+
inHunk = false;
|
|
436
|
+
parseFileHeader(line, currentFile);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (parseFileHeader(line, currentFile)) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const hunkInfo = parseHunkHeader(line);
|
|
445
|
+
if (hunkInfo) {
|
|
446
|
+
oldLineNum = hunkInfo.oldStart;
|
|
447
|
+
newLineNum = hunkInfo.newStart;
|
|
448
|
+
inHunk = true;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (!inHunk) continue;
|
|
453
|
+
|
|
454
|
+
if (line.startsWith('\\ No newline')) continue;
|
|
455
|
+
|
|
456
|
+
const filePath = currentFile.newPath || currentFile.oldPath;
|
|
457
|
+
if (!filePath) continue;
|
|
458
|
+
|
|
459
|
+
if (line.startsWith('+')) {
|
|
460
|
+
entries.add(`${filePath}:RIGHT:${newLineNum}`);
|
|
461
|
+
newLineNum++;
|
|
462
|
+
} else if (line.startsWith('-')) {
|
|
463
|
+
entries.add(`${filePath}:LEFT:${oldLineNum}`);
|
|
464
|
+
oldLineNum++;
|
|
465
|
+
} else if (line.startsWith(' ') || line === '') {
|
|
466
|
+
entries.add(`${filePath}:LEFT:${oldLineNum}`);
|
|
467
|
+
entries.add(`${filePath}:RIGHT:${newLineNum}`);
|
|
468
|
+
oldLineNum++;
|
|
469
|
+
newLineNum++;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
isLineInDiff(file, lineNum, side = 'RIGHT') {
|
|
475
|
+
return entries.has(`${file}:${side}:${lineNum}`);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
407
480
|
module.exports = {
|
|
408
481
|
annotateDiff,
|
|
409
482
|
parseAnnotatedDiff,
|
|
410
483
|
parseHunkHeader,
|
|
411
484
|
formatLineNum,
|
|
412
485
|
getLineMarker,
|
|
413
|
-
getLineContent
|
|
486
|
+
getLineContent,
|
|
487
|
+
buildDiffLineSet
|
|
414
488
|
};
|