@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.
- package/README.md +77 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +4 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
- package/public/css/pr.css +1962 -114
- package/public/js/CONVENTIONS.md +16 -0
- package/public/js/components/AIPanel.js +66 -0
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +2955 -0
- package/public/js/components/CouncilProgressModal.js +12 -16
- package/public/js/components/KeyboardShortcuts.js +3 -0
- package/public/js/components/PanelGroup.js +723 -0
- package/public/js/components/PreviewModal.js +3 -8
- package/public/js/index.js +8 -0
- package/public/js/local.js +17 -615
- package/public/js/modules/analysis-history.js +19 -68
- package/public/js/modules/comment-manager.js +103 -20
- package/public/js/modules/diff-context.js +176 -0
- package/public/js/modules/diff-renderer.js +30 -0
- package/public/js/modules/file-comment-manager.js +126 -105
- package/public/js/modules/file-list-merger.js +64 -0
- package/public/js/modules/panel-resizer.js +25 -6
- package/public/js/modules/suggestion-manager.js +40 -125
- package/public/js/pr.js +1009 -159
- package/public/js/repo-settings.js +36 -6
- package/public/js/utils/category-emoji.js +44 -0
- package/public/js/utils/time.js +32 -0
- package/public/local.html +107 -70
- package/public/pr.html +107 -70
- package/public/repo-settings.html +32 -0
- package/src/ai/analyzer.js +5 -1
- package/src/ai/copilot-provider.js +39 -9
- package/src/ai/cursor-agent-provider.js +45 -11
- package/src/ai/gemini-provider.js +17 -4
- package/src/ai/prompts/config.js +7 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +25 -37
- package/src/chat/CONVENTIONS.md +18 -0
- package/src/chat/pi-bridge.js +491 -0
- package/src/chat/prompt-builder.js +272 -0
- package/src/chat/session-manager.js +619 -0
- package/src/config.js +14 -0
- package/src/database.js +322 -15
- package/src/main.js +4 -17
- package/src/routes/analyses.js +721 -0
- package/src/routes/chat.js +655 -0
- package/src/routes/config.js +29 -8
- package/src/routes/context-files.js +274 -0
- package/src/routes/local.js +225 -1133
- package/src/routes/mcp.js +39 -30
- package/src/routes/pr.js +424 -58
- package/src/routes/reviews.js +1035 -0
- package/src/routes/shared.js +4 -29
- package/src/server.js +34 -12
- package/src/sse/review-events.js +46 -0
- package/src/utils/auto-context.js +88 -0
- package/src/utils/category-emoji.js +33 -0
- package/src/utils/diff-annotator.js +75 -1
- package/src/utils/diff-file-list.js +57 -0
- package/src/routes/analysis.js +0 -1600
- 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/
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|