@in-the-loop-labs/pair-review 3.4.1 → 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.
@@ -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 inside segment-control container, after segment-control-inner
126
- // This positions it on the same row as the segment buttons
127
- const innerControl = this.segmentControl.querySelector('.segment-control-inner');
128
- if (innerControl) {
129
- this.segmentControl.insertBefore(this.filterToggleBtn, innerControl.nextSibling);
130
- } else {
131
- // Fallback: append to the segment control container
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
- return `${file}:${line}:${type}`;
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
- const allCount = aiCount + commentsCount;
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 comments
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
- // Check if this is a comment or a finding
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. User comments are independent
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
- // Always get the .findings-header element directly to avoid parent reference issues
1649
- const headerContainer = document.querySelector('.findings-header');
1650
- if (!headerContainer) return;
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
- // Hide navigation section entirely when there are no items
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
- headerContainer.innerHTML = '';
2101
+ navContainer.innerHTML = '';
1655
2102
  this.findingsCount = null;
1656
2103
  return;
1657
2104
  }
1658
2105
 
1659
- // Update or create the header content (no label - segments already indicate content type)
1660
- headerContainer.innerHTML = `
1661
- <div class="findings-nav">
1662
- <button class="findings-nav-btn nav-prev" title="Previous item (k)">
1663
- <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
1664
- <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"/>
1665
- </svg>
1666
- </button>
1667
- <span class="findings-counter" id="findings-count">${currentDisplay} of ${itemCount}</span>
1668
- <button class="findings-nav-btn nav-next" title="Next item (j)">
1669
- <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
1670
- <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"/>
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
- // Re-bind reference to findings count
1677
- this.findingsCount = headerContainer.querySelector('#findings-count');
2120
+ this.findingsCount = navContainer.querySelector('#findings-count');
1678
2121
 
1679
- // Bind nav button events
1680
- const prevBtn = headerContainer.querySelector('.nav-prev');
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
  }