@in-the-loop-labs/pair-review 3.4.0 → 3.5.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.
- package/README.md +24 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +762 -6
- package/public/js/components/AIPanel.js +486 -42
- package/public/js/components/ChatPanel.js +2002 -528
- package/public/js/index.js +43 -0
- package/public/js/modules/comment-minimizer.js +66 -20
- package/public/js/modules/external-comment-manager.js +870 -0
- package/public/js/pr.js +297 -20
- package/public/local.html +21 -5
- package/public/pr.html +31 -5
- package/src/chat/chat-providers.js +64 -12
- package/src/config.js +21 -17
- package/src/database.js +580 -2
- package/src/external/github-adapter.js +152 -0
- package/src/external/index.js +37 -0
- package/src/git/fetch-helpers.js +29 -0
- package/src/git/worktree-pool-lifecycle.js +16 -5
- package/src/git/worktree.js +9 -8
- package/src/github/client.js +77 -1
- package/src/local-review.js +3 -0
- package/src/main.js +6 -3
- package/src/routes/config.js +27 -0
- package/src/routes/external-comments.js +394 -0
- package/src/routes/local.js +7 -0
- package/src/routes/setup.js +7 -0
- package/src/routes/stack-analysis.js +1 -1
- package/src/server.js +9 -0
- package/src/setup/local-setup.js +5 -1
- package/src/setup/pr-setup.js +7 -4
- package/src/single-port.js +2 -0
- package/src/utils/local-path-input.js +44 -0
|
@@ -34,6 +34,7 @@ class AIPanel {
|
|
|
34
34
|
this.isCollapsed = this.panel?.classList.contains('collapsed') ?? false;
|
|
35
35
|
this.findings = [];
|
|
36
36
|
this.comments = [];
|
|
37
|
+
this.externalThreads = [];
|
|
37
38
|
this.selectedLevel = 'final';
|
|
38
39
|
this.selectedSegment = 'ai'; // Default to AI segment until PR loads
|
|
39
40
|
this.currentIndex = -1; // Current navigation index
|
|
@@ -41,7 +42,7 @@ class AIPanel {
|
|
|
41
42
|
this.analysisState = 'unknown'; // 'unknown' | 'loading' | 'complete' | 'none'
|
|
42
43
|
|
|
43
44
|
// Track selected item by stable identifier for restoration
|
|
44
|
-
this.selectedItemKey = null; // Format: "file:lineNumber:itemType"
|
|
45
|
+
this.selectedItemKey = null; // Format: "file:lineNumber:itemType:identity"
|
|
45
46
|
|
|
46
47
|
// Canonical file order for consistent sorting across components
|
|
47
48
|
this.fileOrder = new Map(); // Map of file path -> index
|
|
@@ -52,6 +53,19 @@ class AIPanel {
|
|
|
52
53
|
this.initElements();
|
|
53
54
|
this.bindEvents();
|
|
54
55
|
this.setupKeyboardNavigation();
|
|
56
|
+
this.setupSegmentOverflow();
|
|
57
|
+
// Hide the External segment when:
|
|
58
|
+
// 1. Local mode — no external source exists for local reviews.
|
|
59
|
+
// 2. The `external_comments` feature toggle is off in config.
|
|
60
|
+
// Both are synchronous flags (set before this constructor runs) so
|
|
61
|
+
// the segment never flashes into view when it shouldn't.
|
|
62
|
+
if (typeof window !== 'undefined') {
|
|
63
|
+
const localMode = window.PAIR_REVIEW_LOCAL_MODE;
|
|
64
|
+
const externalDisabled = window.PAIR_REVIEW_RUNTIME_CONFIG?.external_comments_enabled === false;
|
|
65
|
+
if (localMode || externalDisabled) {
|
|
66
|
+
this.segmentExternalBtn?.setAttribute('hidden', '');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
55
69
|
// Don't restore segment on init - wait for setPR() call
|
|
56
70
|
|
|
57
71
|
// Set CSS variable immediately based on collapsed state to prevent flicker
|
|
@@ -80,7 +94,11 @@ class AIPanel {
|
|
|
80
94
|
|
|
81
95
|
// Segment control
|
|
82
96
|
this.segmentControl = document.getElementById('segment-control');
|
|
97
|
+
this.segmentControlScroll = document.getElementById('segment-control-scroll');
|
|
83
98
|
this.segmentBtns = this.segmentControl?.querySelectorAll('.segment-btn');
|
|
99
|
+
this.segmentScrollLeft = document.getElementById('segment-scroll-left');
|
|
100
|
+
this.segmentScrollRight = document.getElementById('segment-scroll-right');
|
|
101
|
+
this.segmentExternalBtn = this.segmentControl?.querySelector('.segment-btn[data-segment="external"]');
|
|
84
102
|
|
|
85
103
|
// Create filter toggle button (will be inserted after segment control)
|
|
86
104
|
this.filterToggleBtn = null; // Created dynamically in createFilterToggle()
|
|
@@ -122,13 +140,14 @@ class AIPanel {
|
|
|
122
140
|
}
|
|
123
141
|
this.filterToggleBtn.setAttribute('aria-label', this.filterToggleBtn.title);
|
|
124
142
|
|
|
125
|
-
// Insert
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
143
|
+
// Insert as the LAST child of segment-control so it sits at the
|
|
144
|
+
// right edge of the row, after the segment buttons and the right
|
|
145
|
+
// overflow chevron. Previous versions used insertBefore relative
|
|
146
|
+
// to .segment-control-inner — that breaks now that the inner row
|
|
147
|
+
// is nested inside a scroll wrapper. Append-to-end is correct for
|
|
148
|
+
// both old and new structures and avoids the cross-parent
|
|
149
|
+
// insertBefore footgun.
|
|
150
|
+
if (this.segmentControl) {
|
|
132
151
|
this.segmentControl.appendChild(this.filterToggleBtn);
|
|
133
152
|
}
|
|
134
153
|
|
|
@@ -168,6 +187,14 @@ class AIPanel {
|
|
|
168
187
|
if (this.filterToggleBtn) {
|
|
169
188
|
this.filterToggleBtn.addEventListener('click', () => this.onFilterToggle());
|
|
170
189
|
}
|
|
190
|
+
|
|
191
|
+
// Segment overflow scroll chevrons
|
|
192
|
+
if (this.segmentScrollLeft) {
|
|
193
|
+
this.segmentScrollLeft.addEventListener('click', () => this.scrollSegmentRow(-1));
|
|
194
|
+
}
|
|
195
|
+
if (this.segmentScrollRight) {
|
|
196
|
+
this.segmentScrollRight.addEventListener('click', () => this.scrollSegmentRow(1));
|
|
197
|
+
}
|
|
171
198
|
}
|
|
172
199
|
|
|
173
200
|
/**
|
|
@@ -340,6 +367,23 @@ class AIPanel {
|
|
|
340
367
|
this._restoreOrCollapsePanel();
|
|
341
368
|
this.restoreSegmentSelection();
|
|
342
369
|
this.restoreFilterState();
|
|
370
|
+
|
|
371
|
+
// If the external-comment manager finished its initial fetch BEFORE
|
|
372
|
+
// the panel was wired up (race on slow loads), pull the threads it
|
|
373
|
+
// already has so the External segment count is correct on first
|
|
374
|
+
// paint. Guarded behind a function check so tests that stub the
|
|
375
|
+
// manager don't blow up.
|
|
376
|
+
if (typeof window !== 'undefined'
|
|
377
|
+
&& window.externalCommentManager
|
|
378
|
+
&& typeof window.externalCommentManager.getAllThreads === 'function') {
|
|
379
|
+
try {
|
|
380
|
+
this.setExternalThreads(window.externalCommentManager.getAllThreads());
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if (typeof console !== 'undefined') {
|
|
383
|
+
console.warn('[AIPanel] initial external thread sync failed', err);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
343
387
|
}
|
|
344
388
|
|
|
345
389
|
/**
|
|
@@ -358,7 +402,7 @@ class AIPanel {
|
|
|
358
402
|
setFileOrder(orderMap) {
|
|
359
403
|
this.fileOrder = orderMap || new Map();
|
|
360
404
|
// Re-render to apply new ordering
|
|
361
|
-
if (this.findings.length > 0 || this.comments.length > 0) {
|
|
405
|
+
if (this.findings.length > 0 || this.comments.length > 0 || (this.externalThreads?.length ?? 0) > 0) {
|
|
362
406
|
this.renderFindings();
|
|
363
407
|
}
|
|
364
408
|
}
|
|
@@ -385,13 +429,23 @@ class AIPanel {
|
|
|
385
429
|
restoreSegmentSelection() {
|
|
386
430
|
if (!this.segmentBtns) return;
|
|
387
431
|
|
|
432
|
+
// Set of segment values currently available in the DOM. In Local mode
|
|
433
|
+
// the External button is hidden — never restore to it. Any stored
|
|
434
|
+
// value not present in the bar (legacy or hidden) falls back to 'ai'.
|
|
435
|
+
const availableSegments = new Set();
|
|
436
|
+
this.segmentBtns.forEach(btn => {
|
|
437
|
+
if (!btn.hasAttribute('hidden')) {
|
|
438
|
+
availableSegments.add(btn.dataset.segment);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
388
442
|
// Only restore if we have a PR key
|
|
389
443
|
if (this.currentPRKey) {
|
|
390
444
|
const stored = localStorage.getItem(`reviewPanelSegment_${this.currentPRKey}`);
|
|
391
|
-
if (stored) {
|
|
445
|
+
if (stored && availableSegments.has(stored)) {
|
|
392
446
|
this.selectedSegment = stored;
|
|
393
447
|
} else {
|
|
394
|
-
// Default to 'ai' for new PRs
|
|
448
|
+
// Default to 'ai' for new PRs or unknown/hidden stored values
|
|
395
449
|
this.selectedSegment = 'ai';
|
|
396
450
|
}
|
|
397
451
|
}
|
|
@@ -526,7 +580,14 @@ class AIPanel {
|
|
|
526
580
|
const file = item.file || '';
|
|
527
581
|
const line = item.line_start || 0;
|
|
528
582
|
const type = item._itemType || 'finding';
|
|
529
|
-
|
|
583
|
+
// External threads can share a (file, line) anchor — multiple GitHub
|
|
584
|
+
// review threads on the same line collide otherwise. Disambiguate
|
|
585
|
+
// with source + external_id (falling back to id) so selection survives
|
|
586
|
+
// re-render to the thread the reviewer actually picked.
|
|
587
|
+
const identity = item._itemType === 'external'
|
|
588
|
+
? `${item.source || ''}:${item.external_id ?? item.id ?? ''}`
|
|
589
|
+
: (item.id ?? '');
|
|
590
|
+
return `${file}:${line}:${type}:${identity}`;
|
|
530
591
|
}
|
|
531
592
|
|
|
532
593
|
/**
|
|
@@ -579,7 +640,15 @@ class AIPanel {
|
|
|
579
640
|
updateSegmentCounts() {
|
|
580
641
|
const aiCount = this.findings.length;
|
|
581
642
|
const commentsCount = this.comments.length;
|
|
582
|
-
|
|
643
|
+
// External threads are PR-only; hidden in Local mode. The button is
|
|
644
|
+
// [hidden], but the count is harmless to compute either way.
|
|
645
|
+
// Defensive: legacy test fixtures construct panels via Object.create
|
|
646
|
+
// without externalThreads — fall back to an empty array length.
|
|
647
|
+
const externalCount = this.externalThreads?.length ?? 0;
|
|
648
|
+
// 'All' = every visible category. In Local mode the External button
|
|
649
|
+
// is hidden and externalThreads stays at length 0 anyway, so the sum
|
|
650
|
+
// collapses naturally.
|
|
651
|
+
const allCount = aiCount + commentsCount + externalCount;
|
|
583
652
|
|
|
584
653
|
if (this.segmentBtns) {
|
|
585
654
|
this.segmentBtns.forEach(btn => {
|
|
@@ -596,12 +665,18 @@ class AIPanel {
|
|
|
596
665
|
} else if (segment === 'comments') {
|
|
597
666
|
count = commentsCount;
|
|
598
667
|
countSpan.textContent = `(${commentsCount})`;
|
|
668
|
+
} else if (segment === 'external') {
|
|
669
|
+
count = externalCount;
|
|
670
|
+
countSpan.textContent = `(${externalCount})`;
|
|
599
671
|
}
|
|
600
672
|
// Dim the count when zero
|
|
601
673
|
countSpan.classList.toggle('segment-count--zero', count === 0);
|
|
602
674
|
}
|
|
603
675
|
});
|
|
604
676
|
}
|
|
677
|
+
// Count text changes (e.g. "(0)" → "(12)") can alter scrollWidth
|
|
678
|
+
// without triggering resize/scroll listeners, so re-check chevrons.
|
|
679
|
+
this.updateSegmentScrollChevrons?.();
|
|
605
680
|
}
|
|
606
681
|
|
|
607
682
|
/**
|
|
@@ -617,12 +692,18 @@ class AIPanel {
|
|
|
617
692
|
case 'comments':
|
|
618
693
|
items = this.comments.map(c => ({ ...c, _itemType: 'comment' }));
|
|
619
694
|
break;
|
|
695
|
+
case 'external':
|
|
696
|
+
items = (this.externalThreads || []).map(t => this._normalizeExternalThread(t));
|
|
697
|
+
break;
|
|
620
698
|
case 'all':
|
|
621
699
|
default:
|
|
622
|
-
// Combine findings and
|
|
700
|
+
// Combine findings, comments, and external threads.
|
|
701
|
+
// In Local mode externalThreads is always empty so this
|
|
702
|
+
// collapses to findings + comments, matching prior behavior.
|
|
623
703
|
items = [
|
|
624
704
|
...this.findings.map(f => ({ ...f, _itemType: 'finding' })),
|
|
625
|
-
...this.comments.map(c => ({ ...c, _itemType: 'comment' }))
|
|
705
|
+
...this.comments.map(c => ({ ...c, _itemType: 'comment' })),
|
|
706
|
+
...(this.externalThreads || []).map(t => this._normalizeExternalThread(t))
|
|
626
707
|
];
|
|
627
708
|
break;
|
|
628
709
|
}
|
|
@@ -631,6 +712,33 @@ class AIPanel {
|
|
|
631
712
|
return this.sortItemsByFileOrder(items);
|
|
632
713
|
}
|
|
633
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Project an external thread root onto the same shape sortItemsByFileOrder
|
|
717
|
+
* uses (file + line_start), preferring live coordinates and falling back
|
|
718
|
+
* to original_* when the thread is outdated. Returns an object that
|
|
719
|
+
* preserves the source thread fields for downstream renderers.
|
|
720
|
+
* @private
|
|
721
|
+
*/
|
|
722
|
+
_normalizeExternalThread(thread) {
|
|
723
|
+
if (!thread) return { _itemType: 'external' };
|
|
724
|
+
const outdated = thread.is_outdated === 1 || thread.is_outdated === true;
|
|
725
|
+
const liveStart = Number.isFinite(thread.line_start) ? thread.line_start : null;
|
|
726
|
+
const origStart = Number.isFinite(thread.original_line_start) ? thread.original_line_start : null;
|
|
727
|
+
const liveEnd = Number.isFinite(thread.line_end) ? thread.line_end : null;
|
|
728
|
+
const origEnd = Number.isFinite(thread.original_line_end) ? thread.original_line_end : null;
|
|
729
|
+
const lineStart = outdated ? (origStart ?? liveStart) : (liveStart ?? origStart);
|
|
730
|
+
const lineEnd = outdated ? (origEnd ?? liveEnd) : (liveEnd ?? origEnd);
|
|
731
|
+
return {
|
|
732
|
+
...thread,
|
|
733
|
+
_itemType: 'external',
|
|
734
|
+
// Surface line_start at the top level so sort + display helpers
|
|
735
|
+
// do not have to know about the outdated fallback rule.
|
|
736
|
+
line_start: lineStart,
|
|
737
|
+
line_end: lineEnd,
|
|
738
|
+
is_outdated: outdated
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
634
742
|
/**
|
|
635
743
|
* Sort items by canonical file order, with file-level comments first, then by line number.
|
|
636
744
|
* @param {Array} items - Array of items to sort
|
|
@@ -701,6 +809,15 @@ class AIPanel {
|
|
|
701
809
|
<div class="empty-state-description">Click <strong>Analyze</strong> to get AI suggestions</div>
|
|
702
810
|
`;
|
|
703
811
|
}
|
|
812
|
+
} else if (this.selectedSegment === 'external') {
|
|
813
|
+
// External threads come from GitHub PR review activity. There
|
|
814
|
+
// is no in-app action that creates them — surface that
|
|
815
|
+
// expectation rather than the generic "no items yet" copy.
|
|
816
|
+
emptyContent = `
|
|
817
|
+
<div class="empty-state-icon">${this.getEmptyStateIcon('comment')}</div>
|
|
818
|
+
<div class="empty-state-title">No external review comments</div>
|
|
819
|
+
<div class="empty-state-description">Comments from GitHub PR reviews appear here once reviewers leave them.</div>
|
|
820
|
+
`;
|
|
704
821
|
} else {
|
|
705
822
|
// 'all' segment - check analysis state to determine empty message
|
|
706
823
|
if (this.analysisState === 'complete') {
|
|
@@ -732,9 +849,11 @@ class AIPanel {
|
|
|
732
849
|
this.updateFindingsHeader(items.length);
|
|
733
850
|
|
|
734
851
|
this.findingsList.innerHTML = items.map((item, index) => {
|
|
735
|
-
//
|
|
852
|
+
// Dispatch on _itemType so each renderer owns its DOM contract.
|
|
736
853
|
if (item._itemType === 'comment') {
|
|
737
854
|
return this.renderCommentItem(item, index);
|
|
855
|
+
} else if (item._itemType === 'external') {
|
|
856
|
+
return this.renderExternalThreadItem(item, index);
|
|
738
857
|
} else {
|
|
739
858
|
return this.renderFindingItem(item, index);
|
|
740
859
|
}
|
|
@@ -889,6 +1008,51 @@ class AIPanel {
|
|
|
889
1008
|
return;
|
|
890
1009
|
}
|
|
891
1010
|
|
|
1011
|
+
// External thread chat — mirrors ExternalCommentManager._openThreadChat.
|
|
1012
|
+
// The button carries data-thread-id + data-source; the full thread is
|
|
1013
|
+
// looked up from this.externalThreads so replies are included.
|
|
1014
|
+
if (btn.dataset.itemType === 'external') {
|
|
1015
|
+
const threadId = btn.dataset.threadId;
|
|
1016
|
+
const numericId = threadId != null && threadId !== '' ? Number(threadId) : null;
|
|
1017
|
+
const thread = (this.externalThreads || []).find(t =>
|
|
1018
|
+
String(t.id) === String(threadId) ||
|
|
1019
|
+
(numericId != null && t.id === numericId)
|
|
1020
|
+
);
|
|
1021
|
+
if (thread) {
|
|
1022
|
+
const outdated = thread.is_outdated === 1 || thread.is_outdated === true;
|
|
1023
|
+
const replies = Array.isArray(thread.replies) ? thread.replies : [];
|
|
1024
|
+
window.chatPanel.open({
|
|
1025
|
+
reviewId,
|
|
1026
|
+
threadContext: {
|
|
1027
|
+
rootId: thread.id,
|
|
1028
|
+
source: 'external',
|
|
1029
|
+
externalSource: thread.source,
|
|
1030
|
+
file: thread.file,
|
|
1031
|
+
side: thread.side || 'RIGHT',
|
|
1032
|
+
line_start: outdated ? thread.original_line_start : thread.line_start,
|
|
1033
|
+
line_end: outdated ? thread.original_line_end : thread.line_end,
|
|
1034
|
+
comments: [
|
|
1035
|
+
{
|
|
1036
|
+
author: thread.author,
|
|
1037
|
+
body: thread.body,
|
|
1038
|
+
isOutdated: !!outdated,
|
|
1039
|
+
externalUrl: thread.external_url,
|
|
1040
|
+
externalCreatedAt: thread.external_created_at,
|
|
1041
|
+
},
|
|
1042
|
+
...replies.map((r) => ({
|
|
1043
|
+
author: r.author,
|
|
1044
|
+
body: r.body,
|
|
1045
|
+
isOutdated: !!(r.is_outdated === 1 || r.is_outdated === true),
|
|
1046
|
+
externalUrl: r.external_url,
|
|
1047
|
+
externalCreatedAt: r.external_created_at,
|
|
1048
|
+
})),
|
|
1049
|
+
],
|
|
1050
|
+
},
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
892
1056
|
const file = btn.dataset.findingFile || '';
|
|
893
1057
|
const title = btn.dataset.findingTitle || '';
|
|
894
1058
|
let suggestionContext = { title, file };
|
|
@@ -953,6 +1117,14 @@ class AIPanel {
|
|
|
953
1117
|
return;
|
|
954
1118
|
}
|
|
955
1119
|
|
|
1120
|
+
// Handle external threads - scroll to inline external-comment-row
|
|
1121
|
+
if (itemType === 'external') {
|
|
1122
|
+
const source = item.dataset.source || '';
|
|
1123
|
+
const threadId = item.dataset.threadId || itemId;
|
|
1124
|
+
this.scrollToExternalThread(threadId, source, file, line);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
956
1128
|
// Handle findings/suggestions
|
|
957
1129
|
this.scrollToFinding(itemId, file, line);
|
|
958
1130
|
}
|
|
@@ -1127,6 +1299,167 @@ class AIPanel {
|
|
|
1127
1299
|
}
|
|
1128
1300
|
}
|
|
1129
1301
|
|
|
1302
|
+
/**
|
|
1303
|
+
* Scroll to an external review-comment thread in the diff view.
|
|
1304
|
+
*
|
|
1305
|
+
* Mirrors `scrollToComment`: expand the file if collapsed, find the
|
|
1306
|
+
* `.external-comment-row` for the (threadId, source) pair, scroll it into
|
|
1307
|
+
* view, and add a transient focus class for the visual flash.
|
|
1308
|
+
*
|
|
1309
|
+
* @param {string|number} threadId - Root comment id of the thread
|
|
1310
|
+
* @param {string} source - External source key (e.g. 'github')
|
|
1311
|
+
* @param {string} file - File path for collapse-expand fallback
|
|
1312
|
+
* @param {string|number} line - Anchor line; used for file/line fallback
|
|
1313
|
+
*/
|
|
1314
|
+
scrollToExternalThread(threadId, source, file, line) {
|
|
1315
|
+
// Expand the file first if it's collapsed
|
|
1316
|
+
const wasExpanded = this.expandFileIfCollapsed(file);
|
|
1317
|
+
|
|
1318
|
+
const doScroll = () => {
|
|
1319
|
+
let target = null;
|
|
1320
|
+
|
|
1321
|
+
// Most reliable: match on (threadId, source). `data-thread-id`
|
|
1322
|
+
// and `data-source` are written by ExternalCommentManager._buildThreadRow.
|
|
1323
|
+
if (threadId) {
|
|
1324
|
+
const idAttr = (typeof globalThis !== 'undefined' && globalThis.CSS?.escape)
|
|
1325
|
+
? globalThis.CSS.escape(String(threadId))
|
|
1326
|
+
: String(threadId);
|
|
1327
|
+
if (source) {
|
|
1328
|
+
const srcAttr = (typeof globalThis !== 'undefined' && globalThis.CSS?.escape)
|
|
1329
|
+
? globalThis.CSS.escape(String(source))
|
|
1330
|
+
: String(source);
|
|
1331
|
+
target = document.querySelector(
|
|
1332
|
+
`.external-comment-row[data-thread-id="${idAttr}"][data-source="${srcAttr}"]`
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
if (!target) {
|
|
1336
|
+
target = document.querySelector(
|
|
1337
|
+
`.external-comment-row[data-thread-id="${idAttr}"]`
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Fallback: scan within the matching file by anchor line. Useful
|
|
1343
|
+
// when the row was rebuilt and IDs are momentarily missing.
|
|
1344
|
+
if (!target && file) {
|
|
1345
|
+
const rows = document.querySelectorAll('.external-comment-row');
|
|
1346
|
+
for (const row of rows) {
|
|
1347
|
+
const rowFile = row.closest('[data-file-name]')?.dataset?.fileName;
|
|
1348
|
+
if (rowFile && rowFile === file) {
|
|
1349
|
+
target = row;
|
|
1350
|
+
break;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (target) {
|
|
1356
|
+
const minimizer = window.prManager?.commentMinimizer;
|
|
1357
|
+
if (minimizer?.active) {
|
|
1358
|
+
minimizer.expandForElement(target);
|
|
1359
|
+
const diffRow = minimizer.findDiffRowFor(target);
|
|
1360
|
+
(diffRow || target).scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1361
|
+
} else {
|
|
1362
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Transient focus flash. The class is removed after 2s — if
|
|
1366
|
+
// the row is rebuilt before then, the class is lost with it,
|
|
1367
|
+
// which is fine: the flash is purely cosmetic.
|
|
1368
|
+
target.classList.add('external-comment-row--focused');
|
|
1369
|
+
setTimeout(() => target.classList.remove('external-comment-row--focused'), 2000);
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
|
|
1373
|
+
if (wasExpanded) {
|
|
1374
|
+
setTimeout(doScroll, 50);
|
|
1375
|
+
} else {
|
|
1376
|
+
doScroll();
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// ========================================
|
|
1381
|
+
// Segment overflow scroll
|
|
1382
|
+
// ========================================
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* Set up horizontal overflow scroll for the segment row.
|
|
1386
|
+
*
|
|
1387
|
+
* When the segment buttons don't all fit in the panel width, show
|
|
1388
|
+
* chevron buttons on either side that scroll the row horizontally.
|
|
1389
|
+
* Watches via ResizeObserver (panel width can change with the resizer
|
|
1390
|
+
* handle) and the scroll container's own scroll event (to update
|
|
1391
|
+
* chevron visibility at each end of travel).
|
|
1392
|
+
*/
|
|
1393
|
+
setupSegmentOverflow() {
|
|
1394
|
+
if (!this.segmentControlScroll) return;
|
|
1395
|
+
|
|
1396
|
+
const update = () => this.updateSegmentScrollChevrons();
|
|
1397
|
+
|
|
1398
|
+
// Wire the scroll container's scroll event for visibility updates.
|
|
1399
|
+
this.segmentControlScroll.addEventListener('scroll', update, { passive: true });
|
|
1400
|
+
|
|
1401
|
+
// Observe size changes on the scroll container itself. Triggered by
|
|
1402
|
+
// panel resize, segment hidden/shown (e.g. local-mode gate), and
|
|
1403
|
+
// window resize.
|
|
1404
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1405
|
+
this._segmentResizeObserver = new ResizeObserver(update);
|
|
1406
|
+
this._segmentResizeObserver.observe(this.segmentControlScroll);
|
|
1407
|
+
} else if (typeof window !== 'undefined') {
|
|
1408
|
+
// Fallback for very old browsers — react to window resize at least.
|
|
1409
|
+
window.addEventListener('resize', update);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Initial measurement after layout settles.
|
|
1413
|
+
update();
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Show or hide the segment overflow chevrons based on current scroll
|
|
1418
|
+
* geometry. Idempotent — safe to call from resize, scroll, or after a
|
|
1419
|
+
* segment button is hidden/shown.
|
|
1420
|
+
*/
|
|
1421
|
+
updateSegmentScrollChevrons() {
|
|
1422
|
+
const container = this.segmentControlScroll;
|
|
1423
|
+
if (!container) return;
|
|
1424
|
+
|
|
1425
|
+
const overflow = container.scrollWidth > container.clientWidth + 1;
|
|
1426
|
+
const scrollLeft = container.scrollLeft;
|
|
1427
|
+
const maxScroll = container.scrollWidth - container.clientWidth;
|
|
1428
|
+
const atStart = scrollLeft <= 0;
|
|
1429
|
+
const atEnd = scrollLeft >= maxScroll - 1;
|
|
1430
|
+
|
|
1431
|
+
if (this.segmentScrollLeft) {
|
|
1432
|
+
// Hide left chevron when not overflowing or already at start.
|
|
1433
|
+
if (!overflow || atStart) {
|
|
1434
|
+
this.segmentScrollLeft.setAttribute('hidden', '');
|
|
1435
|
+
} else {
|
|
1436
|
+
this.segmentScrollLeft.removeAttribute('hidden');
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (this.segmentScrollRight) {
|
|
1440
|
+
if (!overflow || atEnd) {
|
|
1441
|
+
this.segmentScrollRight.setAttribute('hidden', '');
|
|
1442
|
+
} else {
|
|
1443
|
+
this.segmentScrollRight.removeAttribute('hidden');
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Scroll the segment row horizontally by approximately one segment width.
|
|
1450
|
+
* @param {number} direction - -1 for left, 1 for right
|
|
1451
|
+
*/
|
|
1452
|
+
scrollSegmentRow(direction) {
|
|
1453
|
+
const container = this.segmentControlScroll;
|
|
1454
|
+
if (!container) return;
|
|
1455
|
+
const amount = 150 * (direction < 0 ? -1 : 1);
|
|
1456
|
+
if (typeof container.scrollBy === 'function') {
|
|
1457
|
+
container.scrollBy({ left: amount, behavior: 'smooth' });
|
|
1458
|
+
} else {
|
|
1459
|
+
container.scrollLeft += amount;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1130
1463
|
getFindingType(finding) {
|
|
1131
1464
|
const type = (finding.type || finding.category || '').toLowerCase();
|
|
1132
1465
|
if (type.includes('bug') || type.includes('error') || type.includes('security')) {
|
|
@@ -1337,6 +1670,87 @@ class AIPanel {
|
|
|
1337
1670
|
`;
|
|
1338
1671
|
}
|
|
1339
1672
|
|
|
1673
|
+
/**
|
|
1674
|
+
* Render a single external review-comment thread item.
|
|
1675
|
+
*
|
|
1676
|
+
* Modeled on `renderCommentItem` so the panel list stays visually
|
|
1677
|
+
* consistent. Differs from comments in:
|
|
1678
|
+
* - no quick-action (adopt/dismiss) buttons — external threads are
|
|
1679
|
+
* read-only mirrors from GitHub.
|
|
1680
|
+
* - a reply-count badge when the thread has replies.
|
|
1681
|
+
* - `data-thread-id` + `data-source` so `scrollToExternalThread` can
|
|
1682
|
+
* find the inline `.external-comment-row` element.
|
|
1683
|
+
* - `.source-<name>` modifier so the blue --ec-* color block applies.
|
|
1684
|
+
*
|
|
1685
|
+
* @param {Object} thread - Normalized external thread (root + replies)
|
|
1686
|
+
* @param {number} index - Item index in the rendered list
|
|
1687
|
+
* @returns {string} HTML string
|
|
1688
|
+
*/
|
|
1689
|
+
renderExternalThreadItem(thread, index) {
|
|
1690
|
+
const source = thread.source || 'github';
|
|
1691
|
+
const author = thread.author || 'reviewer';
|
|
1692
|
+
// Plain-text snippet of the root body for the title slot. Markdown
|
|
1693
|
+
// formatting is stripped so the line stays compact.
|
|
1694
|
+
const rawBody = this.stripMarkdown(thread.body || '');
|
|
1695
|
+
const snippet = this.truncateText(rawBody, 80);
|
|
1696
|
+
|
|
1697
|
+
const fileName = thread.file ? thread.file.split('/').pop() : null;
|
|
1698
|
+
const lineNum = thread.line_start;
|
|
1699
|
+
const fullLocation = fileName ? `${fileName}${lineNum ? ':' + lineNum : ''}` : '';
|
|
1700
|
+
|
|
1701
|
+
const replies = Array.isArray(thread.replies) ? thread.replies : [];
|
|
1702
|
+
// Strict count of comments in the thread (root + replies). Always
|
|
1703
|
+
// shown — replaces the static author dot to give a left-side anchor
|
|
1704
|
+
// that conveys thread size at a glance.
|
|
1705
|
+
const totalComments = 1 + replies.length;
|
|
1706
|
+
const commentNoun = totalComments === 1 ? 'comment' : 'comments';
|
|
1707
|
+
|
|
1708
|
+
const outdatedClass = thread.is_outdated ? ' is-outdated' : '';
|
|
1709
|
+
|
|
1710
|
+
// Compose the tooltip: author + body snippet for quick context.
|
|
1711
|
+
const tooltipBits = [];
|
|
1712
|
+
if (author) tooltipBits.push(author);
|
|
1713
|
+
if (fullLocation) tooltipBits.push(fullLocation);
|
|
1714
|
+
if (rawBody) tooltipBits.push(rawBody.length > 200 ? rawBody.substring(0, 200) + '…' : rawBody);
|
|
1715
|
+
const tooltip = tooltipBits.join(' — ');
|
|
1716
|
+
|
|
1717
|
+
const threadId = thread.id != null ? String(thread.id) : '';
|
|
1718
|
+
|
|
1719
|
+
// Chat button — mirrors the finding/comment quick-action pattern.
|
|
1720
|
+
// Dispatches threadContext (not commentContext) so the chat receives
|
|
1721
|
+
// the full thread + replies, matching the inline header button.
|
|
1722
|
+
// External threads carry GitHub-sourced strings (author, body, file,
|
|
1723
|
+
// source, id). Anything that lands inside a quoted HTML attribute must
|
|
1724
|
+
// be escaped with the attribute-safe helper (escapes ", ', <, >, &) —
|
|
1725
|
+
// the local escapeHtml is text-node-only and leaves quotes intact,
|
|
1726
|
+
// which would let a crafted body break out of the attribute.
|
|
1727
|
+
const attr = window.escapeHtmlAttribute;
|
|
1728
|
+
let chatAction = '';
|
|
1729
|
+
if (document.documentElement.getAttribute('data-chat') === 'available') {
|
|
1730
|
+
chatAction = `
|
|
1731
|
+
<div class="finding-chat-action">
|
|
1732
|
+
<button class="quick-action-btn quick-action-chat" data-thread-id="${attr(threadId)}" data-source="${attr(source)}" data-item-type="external" title="Chat about thread" aria-label="Chat about thread">
|
|
1733
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><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>
|
|
1734
|
+
</button>
|
|
1735
|
+
</div>
|
|
1736
|
+
`;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
return `
|
|
1740
|
+
<div class="finding-item-wrapper">
|
|
1741
|
+
<button class="finding-item ai-panel__list-item ai-panel__list-item--external source-${attr(source)}${outdatedClass}" data-index="${index}" data-id="${attr(threadId)}" data-thread-id="${attr(threadId)}" data-source="${attr(source)}" data-file="${attr(thread.file || '')}" data-line="${lineNum != null ? lineNum : ''}" data-item-type="external" title="${attr(tooltip)}">
|
|
1742
|
+
<span class="external-list-count" title="${totalComments} ${commentNoun}" aria-label="${totalComments} ${commentNoun} in thread">${totalComments}</span>
|
|
1743
|
+
<div class="finding-content">
|
|
1744
|
+
<span class="finding-title"><span class="external-list-author">${this.escapeHtml(author)}</span><span class="external-list-snippet">${snippet ? ' — ' + this.escapeHtml(snippet) : ''}</span></span>
|
|
1745
|
+
${thread.is_outdated ? '<span class="finding-meta"><span class="external-list-outdated-badge">outdated</span></span>' : ''}
|
|
1746
|
+
${fileName ? `<span class="finding-location">${this.escapeHtml(fileName)}${lineNum ? ':' + lineNum : ''}</span>` : ''}
|
|
1747
|
+
</div>
|
|
1748
|
+
</button>
|
|
1749
|
+
${chatAction}
|
|
1750
|
+
</div>
|
|
1751
|
+
`;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1340
1754
|
/**
|
|
1341
1755
|
* Get the comment-ai Octicon SVG for AI-adopted comments
|
|
1342
1756
|
* @returns {string} SVG HTML string
|
|
@@ -1480,7 +1894,8 @@ class AIPanel {
|
|
|
1480
1894
|
*/
|
|
1481
1895
|
clearAllFindings() {
|
|
1482
1896
|
this.findings = [];
|
|
1483
|
-
// NOTE: Do NOT clear this.comments here.
|
|
1897
|
+
// NOTE: Do NOT clear this.comments or this.externalThreads here.
|
|
1898
|
+
// User comments and external review-comment threads are independent
|
|
1484
1899
|
// of AI analysis and must persist across analysis runs.
|
|
1485
1900
|
this.currentIndex = -1; // Reset navigation
|
|
1486
1901
|
this.updateSegmentCounts();
|
|
@@ -1533,6 +1948,33 @@ class AIPanel {
|
|
|
1533
1948
|
}
|
|
1534
1949
|
}
|
|
1535
1950
|
|
|
1951
|
+
/**
|
|
1952
|
+
* Replace the set of external comment threads displayed in the External
|
|
1953
|
+
* segment. Mirrors {@link setComments}: replaces state, recomputes the
|
|
1954
|
+
* count badge, re-renders the visible list (if the user is on External
|
|
1955
|
+
* or All), and preserves any restorable selection.
|
|
1956
|
+
*
|
|
1957
|
+
* The panel never owns inline external rows — they live in
|
|
1958
|
+
* `.external-comment-row` elements rendered by ExternalCommentManager.
|
|
1959
|
+
* Pass the flattened union of `threadsBySource.values()`.
|
|
1960
|
+
*
|
|
1961
|
+
* @param {Array<Object>} threads - Flattened external threads (roots).
|
|
1962
|
+
*/
|
|
1963
|
+
setExternalThreads(threads) {
|
|
1964
|
+
// Save current selection before updating so the active item survives
|
|
1965
|
+
// a re-render when its identity is still in the new list.
|
|
1966
|
+
this.saveCurrentSelection();
|
|
1967
|
+
|
|
1968
|
+
this.externalThreads = Array.isArray(threads) ? threads : [];
|
|
1969
|
+
this.updateSegmentCounts();
|
|
1970
|
+
this.renderFindings();
|
|
1971
|
+
|
|
1972
|
+
// Try to restore previous selection, or auto-select first
|
|
1973
|
+
if (!this.restoreSelection()) {
|
|
1974
|
+
this.autoSelectFirst();
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1536
1978
|
/**
|
|
1537
1979
|
* Update an existing comment
|
|
1538
1980
|
* @param {number} commentId - The comment ID
|
|
@@ -1645,40 +2087,40 @@ class AIPanel {
|
|
|
1645
2087
|
const itemCount = items.length;
|
|
1646
2088
|
const currentDisplay = this.currentIndex >= 0 ? (this.currentIndex + 1) : '\u2014';
|
|
1647
2089
|
|
|
1648
|
-
//
|
|
1649
|
-
|
|
1650
|
-
|
|
2090
|
+
// Target the .findings-nav slot only; the sibling .findings-header-actions
|
|
2091
|
+
// (refresh button in PR mode) must persist across re-renders so its
|
|
2092
|
+
// statically-bound click handler stays valid.
|
|
2093
|
+
const navContainer = document.getElementById('findings-nav')
|
|
2094
|
+
|| document.querySelector('.findings-nav');
|
|
2095
|
+
if (!navContainer) return;
|
|
1651
2096
|
|
|
1652
|
-
//
|
|
2097
|
+
// Empty state: blank the nav slot so it collapses, but leave the
|
|
2098
|
+
// sibling actions container alone. The refresh button stays available
|
|
2099
|
+
// even when there are zero items (the reviewer may want to fetch).
|
|
1653
2100
|
if (itemCount === 0) {
|
|
1654
|
-
|
|
2101
|
+
navContainer.innerHTML = '';
|
|
1655
2102
|
this.findingsCount = null;
|
|
1656
2103
|
return;
|
|
1657
2104
|
}
|
|
1658
2105
|
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
<
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
</svg>
|
|
1672
|
-
</button>
|
|
1673
|
-
</div>
|
|
2106
|
+
navContainer.innerHTML = `
|
|
2107
|
+
<button class="findings-nav-btn nav-prev" title="Previous item (k)">
|
|
2108
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
|
|
2109
|
+
<path d="M3.22 9.78a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 0l4.25 4.25a.75.75 0 01-1.06 1.06L8 6.06 4.28 9.78a.75.75 0 01-1.06 0z"/>
|
|
2110
|
+
</svg>
|
|
2111
|
+
</button>
|
|
2112
|
+
<span class="findings-counter" id="findings-count">${currentDisplay} of ${itemCount}</span>
|
|
2113
|
+
<button class="findings-nav-btn nav-next" title="Next item (j)">
|
|
2114
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
|
|
2115
|
+
<path d="M12.78 6.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.22 7.28a.75.75 0 011.06-1.06L8 9.94l3.72-3.72a.75.75 0 011.06 0z"/>
|
|
2116
|
+
</svg>
|
|
2117
|
+
</button>
|
|
1674
2118
|
`;
|
|
1675
2119
|
|
|
1676
|
-
|
|
1677
|
-
this.findingsCount = headerContainer.querySelector('#findings-count');
|
|
2120
|
+
this.findingsCount = navContainer.querySelector('#findings-count');
|
|
1678
2121
|
|
|
1679
|
-
|
|
1680
|
-
const
|
|
1681
|
-
const nextBtn = headerContainer.querySelector('.nav-next');
|
|
2122
|
+
const prevBtn = navContainer.querySelector('.nav-prev');
|
|
2123
|
+
const nextBtn = navContainer.querySelector('.nav-next');
|
|
1682
2124
|
if (prevBtn) {
|
|
1683
2125
|
prevBtn.addEventListener('click', () => this.goToPrevious());
|
|
1684
2126
|
}
|
|
@@ -1768,6 +2210,8 @@ class AIPanel {
|
|
|
1768
2210
|
|
|
1769
2211
|
if (item._itemType === 'comment') {
|
|
1770
2212
|
this.scrollToComment(itemId, file, line);
|
|
2213
|
+
} else if (item._itemType === 'external') {
|
|
2214
|
+
this.scrollToExternalThread(itemId, item.source, file, line);
|
|
1771
2215
|
} else {
|
|
1772
2216
|
this.scrollToFinding(itemId, file, line);
|
|
1773
2217
|
}
|