@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "2.0.0",
3
+ "version": "2.0.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": "2.0.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.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
- /* Highlight flash when navigating to a line from a chat file link */
11202
- .chat-file-link--highlight {
11203
- animation: chat-file-link-flash 2s ease-out;
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
- @keyframes chat-file-link-flash {
11207
- 0% { background-color: rgba(227, 179, 65, 0.25); }
11208
- 100% { background-color: transparent; }
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
- if (!contextFileId) {
2545
- // Fallback: use first chunk's context ID
2546
- const firstChunk = contextEl.querySelector('tbody.context-chunk[data-context-id]');
2547
- if (firstChunk) contextFileId = firstChunk.dataset.contextId;
2548
- }
2549
- if (window.prManager?.scrollToContextFile) {
2550
- window.prManager.scrollToContextFile(file, lineStart, contextFileId);
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, with micro-feedback
2598
- * when the target is already visible.
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
- // Find the target row by line number
2612
- const lineNums = fileWrapper.querySelectorAll('.line-num2');
2613
- let targetRow = null;
2614
- for (const ln of lineNums) {
2615
- if (ln.textContent.trim() === String(lineStart)) {
2616
- targetRow = ln.closest('tr');
2617
- break;
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 (!targetRow) return;
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 = targetRow.getBoundingClientRect();
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
- // Already visible provide micro-feedback instead of scrolling
2628
- this._showLineFeedback(targetRow, lineStart);
2629
- } else {
2630
- // Scroll to the row, then apply highlight
2631
- targetRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
2632
- targetRow.classList.add('chat-file-link--highlight');
2633
- setTimeout(() => targetRow.classList.remove('chat-file-link--highlight'), 2000);
2634
- }
2635
- }
2636
-
2637
- /**
2638
- * Show micro-feedback when a target line is already visible:
2639
- * 1. A brief hop animation (scroll nudge)
2640
- * 2. A temporary gutter arrow indicator
2641
- * @param {HTMLElement} row - The target table row
2642
- * @param {number} lineNum - The line number
2643
- */
2644
- _showLineFeedback(row, lineNum) {
2645
- // Highlight the row
2646
- row.classList.add('chat-file-link--highlight');
2647
- setTimeout(() => row.classList.remove('chat-file-link--highlight'), 2000);
2648
-
2649
- // Inject a temporary gutter arrow
2650
- const lineNumCell = row.querySelector('.d2h-code-linenumber');
2651
- if (lineNumCell) {
2652
- const arrow = document.createElement('span');
2653
- arrow.className = 'chat-gutter-arrow';
2654
- arrow.textContent = '\u2192'; // →
2655
- lineNumCell.appendChild(arrow);
2656
- // Fade out and remove after 1.5s
2657
- setTimeout(() => {
2658
- arrow.classList.add('chat-gutter-arrow--fade');
2659
- arrow.addEventListener('transitionend', () => arrow.remove(), { once: true });
2660
- // Safety cleanup in case transitionend doesn't fire
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
- const isExpandedContext = comment.diff_position === null || comment.diff_position === undefined;
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.add('highlight-flash');
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-flash');
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. Check existing context files (idempotent)
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
- // Use --sandbox enabled for security (when not in yolo mode)
213
- const sandboxArgs = configOverrides.yolo ? ['--sandbox', 'disabled'] : ['--sandbox', 'enabled'];
214
- const baseArgs = ['-p', '--output-format', 'stream-json', '--stream-partial-output', '--model', model, ...sandboxArgs];
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
- const baseArgs = ['-p', '--output-format', 'text', '--model', model];
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 load the pair-review-api skill ${skillRef} for endpoint details. With it you can create, update, and delete review comments, adopt or dismiss AI suggestions, and trigger new analyses via curl.\n` +
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
- // Comments on expanded context lines (diff_position IS NULL) are formatted as file-level
992
- // comments with a "(Ref Line X)" prefix in the body.
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 (no diff_position)
1017
- // These are submitted as file-level comments since GitHub API rejects
1018
- // line-level comments on lines outside diff hunks.
1019
- const isExpandedContext = comment.diff_position === null || comment.diff_position === undefined;
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
  };