@in-the-loop-labs/pair-review 1.6.2 → 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.
Files changed (63) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1962 -114
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2955 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +103 -20
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +1009 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +45 -11
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +272 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +274 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +424 -58
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-annotator.js +75 -1
  61. package/src/utils/diff-file-list.js +57 -0
  62. package/src/routes/analysis.js +0 -1600
  63. package/src/routes/comments.js +0 -534
@@ -147,7 +147,7 @@ class AnalysisHistoryManager {
147
147
  }
148
148
 
149
149
  try {
150
- const response = await fetch(`/api/analysis-runs/${this.reviewId}`);
150
+ const response = await fetch(`/api/analyses/runs?reviewId=${this.reviewId}`);
151
151
  if (!response.ok) {
152
152
  return { runs: [], error: `HTTP ${response.status}` };
153
153
  }
@@ -267,6 +267,7 @@ class AnalysisHistoryManager {
267
267
  </div>
268
268
  <div class="analysis-history-item-meta">
269
269
  <span>${timeAgo}</span>
270
+ ${run.status === 'completed' && run.total_suggestions > 0 ? `<span class="analysis-history-chat-btn" data-run-id="${run.id}" title="Chat about this run" role="button" tabindex="-1"><svg viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/></svg></span>` : ''}
270
271
  </div>
271
272
  </button>
272
273
  `;
@@ -320,6 +321,19 @@ class AnalysisHistoryManager {
320
321
  item.classList.remove('previewing');
321
322
  });
322
323
  });
324
+
325
+ // Chat button handlers
326
+ const chatBtns = this.listElement.querySelectorAll('.analysis-history-chat-btn');
327
+ chatBtns.forEach(btn => {
328
+ btn.addEventListener('click', (e) => {
329
+ e.stopPropagation();
330
+ e.preventDefault();
331
+ const runId = btn.dataset.runId;
332
+ if (window.chatPanel) {
333
+ window.chatPanel.addAnalysisRunContext(runId);
334
+ }
335
+ });
336
+ });
323
337
  }
324
338
 
325
339
  /**
@@ -403,8 +417,7 @@ class AnalysisHistoryManager {
403
417
  const duration = this.formatDuration(run.started_at, run.completed_at);
404
418
  const suggestionCount = run.total_suggestions || 0;
405
419
 
406
- // Get the tier for this analysis run's model
407
- const tier = this.getTierForModel(run.model);
420
+ const tier = run.tier || null;
408
421
 
409
422
  // Format HEAD SHA - show abbreviated version with full SHA in title
410
423
  const headSha = run.head_sha;
@@ -440,7 +453,7 @@ class AnalysisHistoryManager {
440
453
  </div>
441
454
  <div class="analysis-preview-row">
442
455
  <span class="analysis-preview-label">Tier</span>
443
- <span class="analysis-preview-value">${this.escapeHtml(tier || 'unknown')}</span>
456
+ <span class="analysis-preview-value">${this.escapeHtml(this.formatTierDisplayName(tier) || 'unknown')}</span>
444
457
  </div>`;
445
458
  }
446
459
 
@@ -714,23 +727,12 @@ class AnalysisHistoryManager {
714
727
 
715
728
  /**
716
729
  * Parse a timestamp string, ensuring UTC interpretation for SQLite timestamps.
717
- * SQLite's CURRENT_TIMESTAMP produces strings like "2024-01-20 15:30:00" without
718
- * timezone indicator. JavaScript's Date() would interpret these as local time,
719
- * but they're actually UTC. This helper ensures correct UTC parsing.
730
+ * Delegates to the shared window.parseTimestamp utility (see public/js/utils/time.js).
720
731
  * @param {string} timestamp - Timestamp string (ISO 8601 or SQLite format)
721
732
  * @returns {Date} Parsed Date object
722
733
  */
723
734
  parseTimestamp(timestamp) {
724
- if (!timestamp) return new Date(NaN);
725
-
726
- // If the timestamp already has timezone info (ends with Z or +/-offset), parse as-is
727
- if (/Z$|[+-]\d{2}:\d{2}$/.test(timestamp)) {
728
- return new Date(timestamp);
729
- }
730
-
731
- // SQLite CURRENT_TIMESTAMP format: "YYYY-MM-DD HH:MM:SS" (no timezone, but is UTC)
732
- // Append 'Z' to interpret as UTC
733
- return new Date(timestamp + 'Z');
735
+ return window.parseTimestamp(timestamp);
734
736
  }
735
737
 
736
738
  /**
@@ -834,57 +836,6 @@ class AnalysisHistoryManager {
834
836
  return statusMap[status] || { text: status || 'unknown', cssClass: 'analysis-preview-status-unknown' };
835
837
  }
836
838
 
837
- /**
838
- * Get the tier for a given model ID
839
- * Maps model IDs to their corresponding tiers (fast, balanced, thorough)
840
- * @param {string} modelId - The model identifier (e.g., 'haiku', 'sonnet', 'opus', 'flash', 'pro')
841
- * @returns {string|null} The tier name or null if unknown
842
- */
843
- getTierForModel(modelId) {
844
- if (!modelId) return null;
845
-
846
- // Model to tier mapping (matches backend provider definitions)
847
- const modelTiers = {
848
- // Claude models
849
- 'haiku': 'fast',
850
- 'sonnet': 'balanced',
851
- 'sonnet-4.5': 'balanced',
852
- 'sonnet-4.6': 'balanced',
853
- 'opus': 'thorough',
854
- 'opus-4.5': 'thorough',
855
- 'opus-4.6-low': 'balanced',
856
- 'opus-4.6-medium': 'balanced',
857
- 'opus-4.6-1m': 'balanced',
858
- // Gemini models
859
- 'flash': 'fast',
860
- 'pro': 'balanced',
861
- 'ultra': 'thorough',
862
- // Codex/OpenAI models
863
- 'gpt-4o-mini': 'fast',
864
- 'gpt-4o': 'balanced',
865
- 'o1': 'thorough',
866
- 'o1-mini': 'balanced',
867
- // Copilot models
868
- 'gpt-4': 'balanced',
869
- // Pi models
870
- 'default': 'balanced',
871
- 'multi-model': 'thorough',
872
- 'review-roulette': 'thorough'
873
- };
874
-
875
- return modelTiers[modelId] || null;
876
- }
877
-
878
- /**
879
- * Format a tier name for display (uppercase, used for badges)
880
- * @param {string} tier - The tier identifier (e.g., 'fast', 'balanced', 'thorough')
881
- * @returns {string} Display name in uppercase
882
- */
883
- formatTierName(tier) {
884
- if (!tier) return '';
885
- return tier.toUpperCase();
886
- }
887
-
888
839
  /**
889
840
  * Format a tier name for display as plain text (capitalized)
890
841
  * @param {string} tier - The tier identifier (e.g., 'fast', 'balanced', 'thorough')
@@ -18,6 +18,68 @@ class CommentManager {
18
18
  this.prManager = prManagerRef;
19
19
  // Current comment form element
20
20
  this.currentCommentForm = null;
21
+
22
+ // Event delegation for "Ask about this" chat button on user comments
23
+ document.addEventListener('click', (e) => {
24
+ const chatBtn = e.target.closest('.user-comment-row .btn-chat-comment');
25
+ if (chatBtn && window.chatPanel) {
26
+ e.stopPropagation();
27
+ const commentRow = chatBtn.closest('.user-comment-row');
28
+ const bodyEl = commentRow?.querySelector('.user-comment-body');
29
+ const originalMarkdown = bodyEl?.dataset?.originalMarkdown || bodyEl?.textContent || '';
30
+ window.chatPanel.open({
31
+ reviewId: this.prManager?.currentPR?.id,
32
+ commentContext: {
33
+ commentId: chatBtn.dataset.chatCommentId,
34
+ body: originalMarkdown,
35
+ file: chatBtn.dataset.chatFile || '',
36
+ line_start: chatBtn.dataset.chatLineStart ? parseInt(chatBtn.dataset.chatLineStart) : null,
37
+ line_end: chatBtn.dataset.chatLineEnd ? parseInt(chatBtn.dataset.chatLineEnd) : null,
38
+ parentId: chatBtn.dataset.chatParentId || null,
39
+ source: 'user'
40
+ }
41
+ });
42
+ }
43
+ });
44
+ }
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;
21
83
  }
22
84
 
23
85
  /**
@@ -100,6 +162,10 @@ class CommentManager {
100
162
  ></textarea>
101
163
  <div class="comment-form-actions">
102
164
  <button class="btn btn-sm btn-primary save-comment-btn" disabled>Save</button>
165
+ <button class="ai-action ai-action-chat btn-chat-from-comment" title="Chat about these lines">
166
+ <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16"><path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/></svg>
167
+ Chat
168
+ </button>
103
169
  <button class="btn btn-sm btn-secondary cancel-comment-btn">Cancel</button>
104
170
  </div>
105
171
  </div>
@@ -138,6 +204,31 @@ class CommentManager {
138
204
  }
139
205
  });
140
206
 
207
+ // Chat button handler - opens chat panel with line context card
208
+ const chatFromCommentBtn = td.querySelector('.btn-chat-from-comment');
209
+ if (chatFromCommentBtn) {
210
+ chatFromCommentBtn.addEventListener('click', () => {
211
+ if (!window.chatPanel) return;
212
+ const unsavedText = textarea.value.trim();
213
+ const file = textarea.dataset.file;
214
+ const lineStart = textarea.dataset.line ? parseInt(textarea.dataset.line) : null;
215
+ const lineEnd = textarea.dataset.lineEnd ? parseInt(textarea.dataset.lineEnd) : lineStart;
216
+
217
+ this.hideCommentForm();
218
+ if (lineTracker) lineTracker.clearRangeSelection();
219
+ window.chatPanel.open({
220
+ commentContext: {
221
+ type: 'line',
222
+ body: unsavedText || null,
223
+ file: file || '',
224
+ line_start: lineStart,
225
+ line_end: lineEnd,
226
+ source: 'user'
227
+ }
228
+ });
229
+ });
230
+ }
231
+
141
232
  // Initialize textarea height and suggestion button state
142
233
  this.autoResizeTextarea(textarea);
143
234
  this.updateSuggestionButtonState(textarea, suggestionBtn);
@@ -358,13 +449,10 @@ class CommentManager {
358
449
  const reviewId = this.prManager?.currentPR?.id;
359
450
  const headSha = this.prManager?.currentPR?.head_sha;
360
451
 
361
- const response = await fetch('/api/user-comment', {
452
+ const response = await fetch(`/api/reviews/${reviewId}/comments`, {
362
453
  method: 'POST',
363
- headers: {
364
- 'Content-Type': 'application/json'
365
- },
454
+ headers: { 'Content-Type': 'application/json' },
366
455
  body: JSON.stringify({
367
- review_id: reviewId,
368
456
  file: fileName,
369
457
  line_start: lineNumber,
370
458
  line_end: endLineNumber,
@@ -454,7 +542,13 @@ class CommentManager {
454
542
  // WORKAROUND: Comments on expanded context lines (outside diff hunks) will be
455
543
  // submitted as file-level comments since GitHub's API doesn't support line-level
456
544
  // comments on these lines. Show an indicator to inform the user.
457
- 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);
458
552
  const expandedContextIndicator = isExpandedContext
459
553
  ? `<span class="expanded-context-indicator" title="This expanded context comment will be posted to GitHub as a file-level comment">
460
554
  <svg viewBox="0 0 16 16" width="14" height="14">
@@ -506,8 +600,10 @@ class CommentManager {
506
600
  <span class="user-comment-line-info">${lineInfo}</span>
507
601
  ${expandedContextIndicator}
508
602
  ${metadataHTML}
509
- <span class="user-comment-timestamp">${new Date(comment.created_at).toLocaleString()}</span>
510
603
  <div class="user-comment-actions">
604
+ <button class="btn-chat-comment" title="Chat about comment" data-chat-comment-id="${comment.id}" data-chat-file="${escapeHtml(comment.file || '')}" data-chat-line-start="${comment.line_start ?? ''}" data-chat-line-end="${comment.line_end || comment.line_start || ''}" data-chat-parent-id="${comment.parent_id || ''}">
605
+ <svg viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/></svg>
606
+ </button>
511
607
  <button class="btn-edit-comment" onclick="prManager.editUserComment(${comment.id})" title="Edit comment">
512
608
  <svg class="octicon" viewBox="0 0 16 16" width="16" height="16">
513
609
  <path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path>
@@ -576,19 +672,6 @@ class CommentManager {
576
672
  <span class="user-comment-line-info">${lineInfo}</span>
577
673
  ${comment.type === 'praise' ? `<span class="adopted-praise-badge" title="Nice Work"><svg viewBox="0 0 16 16" width="12" height="12"><path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/></svg>Nice Work</span>` : ''}
578
674
  ${comment.title ? `<span class="adopted-title">${escapeHtml(comment.title)}</span>` : ''}
579
- <span class="user-comment-timestamp">Editing comment...</span>
580
- <div class="user-comment-actions">
581
- <button class="btn-edit-comment" onclick="prManager.editUserComment(${comment.id})" title="Edit comment">
582
- <svg class="octicon" viewBox="0 0 16 16" width="16" height="16">
583
- <path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path>
584
- </svg>
585
- </button>
586
- <button class="btn-delete-comment" onclick="prManager.deleteUserComment(${comment.id})" title="Dismiss comment">
587
- <svg class="octicon" viewBox="0 0 16 16" width="16" height="16">
588
- <path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path>
589
- </svg>
590
- </button>
591
- </div>
592
675
  </div>
593
676
  <!-- Hidden body div for saving - pre-populate with markdown rendered content and store original -->
594
677
  <div class="user-comment-body" style="display: none;" data-original-markdown="${window.escapeHtmlAttribute(comment.body)}">${window.renderMarkdown ? window.renderMarkdown(comment.body) : escapeHtml(comment.body)}</div>
@@ -0,0 +1,176 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * DiffContext - Extract diff hunks for chat context enrichment.
4
+ * Provides utilities to pull relevant unified diff sections for
5
+ * a given line range, so the chat agent receives code context
6
+ * alongside suggestion/comment metadata.
7
+ */
8
+ (function () {
9
+ 'use strict';
10
+
11
+ const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
12
+ const MAX_HUNK_LINES = 100;
13
+ const CONTEXT_PADDING = 20;
14
+
15
+ /**
16
+ * Extract unified diff hunks overlapping the given line range.
17
+ * @param {string} patchText - Raw unified diff for one file (hunk headers + content lines)
18
+ * @param {number} lineStart - Start line number (1-based, new-side unless side='LEFT')
19
+ * @param {number} lineEnd - End line number (1-based, inclusive)
20
+ * @param {string} [side] - 'LEFT' to match old-side line numbers, otherwise new-side
21
+ * @returns {string|null} Matching hunks as unified diff text, or null
22
+ */
23
+ function extractHunkForLines(patchText, lineStart, lineEnd, side) {
24
+ if (!patchText) return null;
25
+
26
+ const lines = patchText.split('\n');
27
+ const hunks = [];
28
+
29
+ // Collect hunks: each hunk is { header, headerLine, contentLines }
30
+ let currentHunk = null;
31
+ for (let i = 0; i < lines.length; i++) {
32
+ const line = lines[i];
33
+ if (line.startsWith('@@')) {
34
+ if (currentHunk) {
35
+ hunks.push(currentHunk);
36
+ }
37
+ currentHunk = { headerLine: line, contentLines: [] };
38
+ } else if (currentHunk) {
39
+ currentHunk.contentLines.push(line);
40
+ }
41
+ }
42
+ if (currentHunk) {
43
+ hunks.push(currentHunk);
44
+ }
45
+
46
+ const matchingParts = [];
47
+
48
+ for (const hunk of hunks) {
49
+ const match = HUNK_HEADER_RE.exec(hunk.headerLine);
50
+ if (!match) continue;
51
+
52
+ const oldStart = parseInt(match[1], 10);
53
+ const oldCount = match[2] !== undefined ? parseInt(match[2], 10) : 1;
54
+ const newStart = parseInt(match[3], 10);
55
+ const newCount = match[4] !== undefined ? parseInt(match[4], 10) : 1;
56
+
57
+ let hunkStart, hunkEnd;
58
+ if (side === 'LEFT') {
59
+ hunkStart = oldStart;
60
+ hunkEnd = oldStart + oldCount - 1;
61
+ } else {
62
+ hunkStart = newStart;
63
+ hunkEnd = newStart + newCount - 1;
64
+ }
65
+
66
+ // Check overlap: [lineStart, lineEnd] vs [hunkStart, hunkEnd]
67
+ if (lineStart > hunkEnd || lineEnd < hunkStart) {
68
+ continue;
69
+ }
70
+
71
+ let contentLines = hunk.contentLines;
72
+
73
+ // Truncate if content exceeds MAX_HUNK_LINES
74
+ if (contentLines.length > MAX_HUNK_LINES) {
75
+ contentLines = truncateHunkContent(
76
+ contentLines,
77
+ lineStart,
78
+ lineEnd,
79
+ side,
80
+ side === 'LEFT' ? oldStart : newStart
81
+ );
82
+ }
83
+
84
+ matchingParts.push(hunk.headerLine + '\n' + contentLines.join('\n'));
85
+ }
86
+
87
+ if (matchingParts.length === 0) return null;
88
+ return matchingParts.join('\n');
89
+ }
90
+
91
+ /**
92
+ * Truncate hunk content lines around the referenced line range.
93
+ * @param {string[]} contentLines - Content lines of the hunk (after @@ header)
94
+ * @param {number} lineStart - Start of the referenced range
95
+ * @param {number} lineEnd - End of the referenced range
96
+ * @param {string} side - 'LEFT' or other
97
+ * @param {number} startCounter - Starting line number for the relevant side
98
+ * @returns {string[]} Truncated content lines with markers
99
+ */
100
+ function truncateHunkContent(contentLines, lineStart, lineEnd, side, startCounter) {
101
+ // Find content-line indices that fall within [lineStart, lineEnd]
102
+ let firstIndex = -1;
103
+ let lastIndex = -1;
104
+ let counter = startCounter;
105
+
106
+ for (let i = 0; i < contentLines.length; i++) {
107
+ const line = contentLines[i];
108
+ const prefix = line.charAt(0);
109
+
110
+ let countsForSide;
111
+ if (side === 'LEFT') {
112
+ // Old-side: '-' and ' ' (context) increment, '+' does not
113
+ countsForSide = prefix === '-' || prefix === ' ';
114
+ } else {
115
+ // New-side: '+' and ' ' (context) increment, '-' does not
116
+ countsForSide = prefix === '+' || prefix === ' ';
117
+ }
118
+
119
+ if (countsForSide) {
120
+ if (counter >= lineStart && counter <= lineEnd) {
121
+ if (firstIndex === -1) firstIndex = i;
122
+ lastIndex = i;
123
+ }
124
+ counter++;
125
+ }
126
+ }
127
+
128
+ // If we didn't find any matching lines, keep the whole thing
129
+ // (the hunk overlaps by header range but maybe content is all removals/additions on the other side)
130
+ if (firstIndex === -1) {
131
+ firstIndex = 0;
132
+ lastIndex = contentLines.length - 1;
133
+ }
134
+
135
+ const keepStart = Math.max(0, firstIndex - CONTEXT_PADDING);
136
+ const keepEnd = Math.min(contentLines.length - 1, lastIndex + CONTEXT_PADDING);
137
+
138
+ const result = [];
139
+ if (keepStart > 0) {
140
+ result.push('// ... (truncated)');
141
+ }
142
+ for (let i = keepStart; i <= keepEnd; i++) {
143
+ result.push(contentLines[i]);
144
+ }
145
+ if (keepEnd < contentLines.length - 1) {
146
+ result.push('// ... (truncated)');
147
+ }
148
+
149
+ return result;
150
+ }
151
+
152
+ /**
153
+ * Get the line ranges covered by all hunks in a patch.
154
+ * @param {string} patchText - Raw unified diff for one file
155
+ * @returns {Array<{start: number, end: number}>} New-side ranges for each hunk
156
+ */
157
+ function extractHunkRangesForFile(patchText) {
158
+ if (!patchText) return [];
159
+
160
+ const lines = patchText.split('\n');
161
+ const ranges = [];
162
+
163
+ for (let i = 0; i < lines.length; i++) {
164
+ const match = HUNK_HEADER_RE.exec(lines[i]);
165
+ if (!match) continue;
166
+
167
+ const newStart = parseInt(match[3], 10);
168
+ const newCount = match[4] !== undefined ? parseInt(match[4], 10) : 1;
169
+ ranges.push({ start: newStart, end: newStart + newCount - 1 });
170
+ }
171
+
172
+ return ranges;
173
+ }
174
+
175
+ window.DiffContext = { extractHunkForLines, extractHunkRangesForFile };
176
+ })();
@@ -293,6 +293,36 @@ class DiffRenderer {
293
293
  options.onCommentButtonClick(e, row, lineNumber, fileName, line);
294
294
  };
295
295
 
296
+ if (options.onChatButtonClick) {
297
+ const chatButton = document.createElement('button');
298
+ chatButton.className = 'chat-line-btn ai-action-chat';
299
+ chatButton.title = 'Chat about this line (drag to select range)';
300
+ chatButton.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
301
+ <path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
302
+ </svg>`;
303
+
304
+ // Share drag machinery with comment button via potentialDragStart
305
+ chatButton.onmousedown = (e) => {
306
+ e.preventDefault();
307
+ e.stopPropagation();
308
+ const side = line.type === 'delete' ? 'LEFT' : 'RIGHT';
309
+ if (options.lineTracker) {
310
+ options.lineTracker.potentialDragStart = {
311
+ row, lineNumber, fileName, button: chatButton,
312
+ isDeletedLine: line.type === 'delete', side, isChat: true
313
+ };
314
+ }
315
+ };
316
+
317
+ chatButton.onclick = (e) => {
318
+ e.preventDefault();
319
+ e.stopPropagation();
320
+ options.onChatButtonClick(e, row, lineNumber, fileName, line);
321
+ };
322
+
323
+ lineNumContent.appendChild(chatButton);
324
+ }
325
+
296
326
  lineNumContent.appendChild(commentButton);
297
327
  }
298
328