@in-the-loop-labs/pair-review 1.6.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) 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 +1930 -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 +2952 -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 +57 -19
  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 +964 -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 +36 -7
  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 +262 -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 +223 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +410 -52
  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-file-list.js +57 -0
  61. package/src/routes/analysis.js +0 -1600
  62. 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,29 @@ 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
+ });
21
44
  }
22
45
 
23
46
  /**
@@ -100,6 +123,10 @@ class CommentManager {
100
123
  ></textarea>
101
124
  <div class="comment-form-actions">
102
125
  <button class="btn btn-sm btn-primary save-comment-btn" disabled>Save</button>
126
+ <button class="ai-action ai-action-chat btn-chat-from-comment" title="Chat about these lines">
127
+ <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>
128
+ Chat
129
+ </button>
103
130
  <button class="btn btn-sm btn-secondary cancel-comment-btn">Cancel</button>
104
131
  </div>
105
132
  </div>
@@ -138,6 +165,31 @@ class CommentManager {
138
165
  }
139
166
  });
140
167
 
168
+ // Chat button handler - opens chat panel with line context card
169
+ const chatFromCommentBtn = td.querySelector('.btn-chat-from-comment');
170
+ if (chatFromCommentBtn) {
171
+ chatFromCommentBtn.addEventListener('click', () => {
172
+ if (!window.chatPanel) return;
173
+ const unsavedText = textarea.value.trim();
174
+ const file = textarea.dataset.file;
175
+ const lineStart = textarea.dataset.line ? parseInt(textarea.dataset.line) : null;
176
+ const lineEnd = textarea.dataset.lineEnd ? parseInt(textarea.dataset.lineEnd) : lineStart;
177
+
178
+ this.hideCommentForm();
179
+ if (lineTracker) lineTracker.clearRangeSelection();
180
+ window.chatPanel.open({
181
+ commentContext: {
182
+ type: 'line',
183
+ body: unsavedText || null,
184
+ file: file || '',
185
+ line_start: lineStart,
186
+ line_end: lineEnd,
187
+ source: 'user'
188
+ }
189
+ });
190
+ });
191
+ }
192
+
141
193
  // Initialize textarea height and suggestion button state
142
194
  this.autoResizeTextarea(textarea);
143
195
  this.updateSuggestionButtonState(textarea, suggestionBtn);
@@ -358,13 +410,10 @@ class CommentManager {
358
410
  const reviewId = this.prManager?.currentPR?.id;
359
411
  const headSha = this.prManager?.currentPR?.head_sha;
360
412
 
361
- const response = await fetch('/api/user-comment', {
413
+ const response = await fetch(`/api/reviews/${reviewId}/comments`, {
362
414
  method: 'POST',
363
- headers: {
364
- 'Content-Type': 'application/json'
365
- },
415
+ headers: { 'Content-Type': 'application/json' },
366
416
  body: JSON.stringify({
367
- review_id: reviewId,
368
417
  file: fileName,
369
418
  line_start: lineNumber,
370
419
  line_end: endLineNumber,
@@ -506,8 +555,10 @@ class CommentManager {
506
555
  <span class="user-comment-line-info">${lineInfo}</span>
507
556
  ${expandedContextIndicator}
508
557
  ${metadataHTML}
509
- <span class="user-comment-timestamp">${new Date(comment.created_at).toLocaleString()}</span>
510
558
  <div class="user-comment-actions">
559
+ <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 || ''}">
560
+ <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>
561
+ </button>
511
562
  <button class="btn-edit-comment" onclick="prManager.editUserComment(${comment.id})" title="Edit comment">
512
563
  <svg class="octicon" viewBox="0 0 16 16" width="16" height="16">
513
564
  <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 +627,6 @@ class CommentManager {
576
627
  <span class="user-comment-line-info">${lineInfo}</span>
577
628
  ${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
629
  ${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
630
  </div>
593
631
  <!-- Hidden body div for saving - pre-populate with markdown rendered content and store original -->
594
632
  <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