@in-the-loop-labs/pair-review 3.5.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +603 -6
  5. package/public/index.html +90 -0
  6. package/public/js/components/ChatPanel.js +163 -3
  7. package/public/js/components/KeyboardShortcuts.js +10 -26
  8. package/public/js/components/TourBar.js +248 -0
  9. package/public/js/index.js +298 -25
  10. package/public/js/local.js +6 -0
  11. package/public/js/modules/cancel-background-job.js +183 -0
  12. package/public/js/modules/hunk-summary-renderer.js +116 -0
  13. package/public/js/modules/storage-cleanup.js +16 -0
  14. package/public/js/modules/tour-renderer.js +725 -0
  15. package/public/js/pr.js +1276 -2
  16. package/public/js/utils/modal-detection.js +77 -0
  17. package/public/local.html +17 -0
  18. package/public/pr.html +17 -0
  19. package/src/ai/abort-signal-wiring.js +130 -0
  20. package/src/ai/background-queue.js +290 -0
  21. package/src/ai/claude-cli.js +1 -1
  22. package/src/ai/claude-provider.js +50 -7
  23. package/src/ai/codex-provider.js +28 -5
  24. package/src/ai/copilot-provider.js +22 -3
  25. package/src/ai/cursor-agent-provider.js +22 -6
  26. package/src/ai/executable-provider.js +4 -19
  27. package/src/ai/gemini-provider.js +22 -5
  28. package/src/ai/hunk-hashing.js +161 -0
  29. package/src/ai/index.js +2 -0
  30. package/src/ai/opencode-provider.js +21 -5
  31. package/src/ai/pi-provider.js +21 -5
  32. package/src/ai/prompts/hunk-summary.js +199 -0
  33. package/src/ai/prompts/tour.js +232 -0
  34. package/src/ai/provider.js +21 -1
  35. package/src/ai/summary-generator.js +469 -0
  36. package/src/ai/tour-generator.js +568 -0
  37. package/src/config.js +114 -0
  38. package/src/database.js +282 -1
  39. package/src/local-review.js +189 -169
  40. package/src/routes/config.js +16 -1
  41. package/src/routes/context-files.js +2 -29
  42. package/src/routes/github-collections.js +168 -90
  43. package/src/routes/local.js +311 -4
  44. package/src/routes/middleware/validate-review-id.js +53 -0
  45. package/src/routes/pr.js +259 -4
  46. package/src/routes/reviews.js +145 -29
  47. package/src/utils/diff-hunks.js +65 -0
  48. package/src/utils/json-extractor.js +5 -2
@@ -303,7 +303,21 @@
303
303
  loaded: false
304
304
  };
305
305
 
306
- // ─── GitHub PR Collections (My Review Requests / My PRs) ───────────────────
306
+ // ─── GitHub PR Collections (Review Requests / Team Reviews / My PRs) ───────
307
+
308
+ /** Empty-state message per collection (used when a fetch returned no PRs). */
309
+ var COLLECTION_EMPTY_MESSAGES = {
310
+ 'review-requests': 'No pull requests awaiting your review.',
311
+ 'team-reviews': 'No pull requests awaiting your team’s review.',
312
+ 'my-prs': 'You have no open pull requests.'
313
+ };
314
+
315
+ /** Noun used in the "Configure a GitHub token to see …" message per collection. */
316
+ var COLLECTION_TOKEN_LABELS = {
317
+ 'review-requests': 'review requests',
318
+ 'team-reviews': 'team review requests',
319
+ 'my-prs': 'your pull requests'
320
+ };
307
321
 
308
322
  var reviewRequestsState = {
309
323
  loaded: false,
@@ -311,16 +325,90 @@
311
325
  fetchedAt: null
312
326
  };
313
327
 
328
+ var teamReviewsState = {
329
+ loaded: false,
330
+ prs: [],
331
+ fetchedAt: null,
332
+ // Monotonic token for the most recent team-reviews request (see
333
+ // beginTeamReviewsRequest). Stale responses compare against this and bail.
334
+ activeRequest: 0
335
+ };
336
+
314
337
  var myPrsState = {
315
338
  loaded: false,
316
339
  prs: [],
317
340
  fetchedAt: null
318
341
  };
319
342
 
343
+ // Filter-by-team support for the Team Review Requests tab.
344
+ // Client-side validation is UX only; the server re-validates before
345
+ // interpolating the value into the GitHub search query (query-injection
346
+ // guard). Pattern must match the server's TEAM_SLUG_PATTERN.
347
+ var TEAM_SLUG_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
348
+ var TEAM_FILTER_LS_KEY = 'github-collection-team:team-reviews';
349
+
350
+ /**
351
+ * Read the persisted team filter, returning '' (all teams) for an absent or
352
+ * invalid stored value.
353
+ * @returns {string} A validated `org/team` slug, or ''.
354
+ */
355
+ function getStoredTeamFilter() {
356
+ try {
357
+ var v = (localStorage.getItem(TEAM_FILTER_LS_KEY) || '').trim();
358
+ return v && TEAM_SLUG_PATTERN.test(v) ? v : '';
359
+ } catch (e) {
360
+ return '';
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Begin a new team-reviews request "epoch" and return its token. Every user
366
+ * action that (re)loads the team-reviews view — filter apply, tab switch,
367
+ * manual refresh, initial load — calls this once and threads the returned id
368
+ * through BOTH the cached GET and the live refresh. Because multiple in-flight
369
+ * fetches write into the shared `teamReviewsState` and render into the same
370
+ * container, completion order alone could otherwise let a slower earlier
371
+ * request (e.g. a previous team, or the all-teams view) repaint the table with
372
+ * PRs that no longer match the active selection. loadCollectionPrs /
373
+ * refreshCollectionPrs bail after `await fetch(...)` when their token is no
374
+ * longer `activeRequest`, discarding the stale response before it mutates
375
+ * state or the DOM.
376
+ * @returns {number} The token for this request epoch.
377
+ */
378
+ function beginTeamReviewsRequest() {
379
+ teamReviewsState.activeRequest += 1;
380
+ return teamReviewsState.activeRequest;
381
+ }
382
+
383
+ /**
384
+ * Resync the Team Review Requests filter controls to a known-applied value
385
+ * (typically getStoredTeamFilter()). Switching tabs only toggles `.active` —
386
+ * it does not rebuild the Team Reviews DOM — so unapplied draft text typed
387
+ * into the input survives a tab switch. Because every reload path keys its
388
+ * fetch off getStoredTeamFilter() (the persisted value) rather than the live
389
+ * input, the visible filter could otherwise advertise one team while the
390
+ * queue, row-click navigation, and bulk actions operate on another. Any path
391
+ * that re-consumes the stored filter must call this first so the displayed
392
+ * filter cannot diverge from the team actually being fetched and rendered.
393
+ * @param {string} value - The validated `org/team` slug to display, or '' for all teams.
394
+ */
395
+ function syncTeamFilterControls(value) {
396
+ var input = document.getElementById('team-reviews-team-input');
397
+ if (input) {
398
+ input.value = value || '';
399
+ input.classList.remove('invalid');
400
+ input.removeAttribute('aria-invalid');
401
+ }
402
+ var hint = document.getElementById('team-reviews-filter-hint');
403
+ if (hint) hint.hidden = true;
404
+ var clearBtn = document.getElementById('team-reviews-filter-clear');
405
+ if (clearBtn) clearBtn.hidden = !value;
406
+ }
407
+
320
408
  /**
321
409
  * Render a single row for a collection PR table.
322
410
  * @param {Object} pr - PR object from the API
323
- * @param {string} collection - The collection name ('review-requests' or 'my-prs')
411
+ * @param {string} collection - The collection name ('review-requests', 'team-reviews', or 'my-prs')
324
412
  * @returns {string} HTML string for the table row
325
413
  */
326
414
  function renderCollectionPrRow(pr, collection) {
@@ -366,15 +454,20 @@
366
454
  * Render the collection table into a container element.
367
455
  * @param {HTMLElement} container - The container element
368
456
  * @param {Object} state - The collection state object
369
- * @param {string} collection - The collection name ('review-requests' or 'my-prs')
457
+ * @param {string} collection - The collection name ('review-requests', 'team-reviews', or 'my-prs')
370
458
  */
371
459
  function renderCollectionTable(container, state, collection) {
372
- var sel = collection === 'review-requests' ? reviewRequestsSelection : myPrsSelection;
373
- sel.exit();
460
+ var sel = collectionSelections[collection];
461
+ if (sel) sel.exit();
374
462
 
375
- var fetchedAtId = collection === 'review-requests' ? 'review-requests-fetched-at' : 'my-prs-fetched-at';
376
- var fetchedAtEl = document.getElementById(fetchedAtId);
463
+ var fetchedAtEl = document.getElementById(collection + '-fetched-at');
377
464
  if (fetchedAtEl) {
465
+ // Note: this key is intentionally NOT per-team — unlike the DB cache key
466
+ // in src/routes/github-collections.js (which uses cacheKey()), the
467
+ // fetched-at timestamp is informational only. We accept a brief label
468
+ // mismatch when the user switches the team-reviews filter; the label
469
+ // updates once that view's next refresh completes. See the write site in
470
+ // refreshCollectionPrs.
378
471
  var lsKey = 'github-collection-fetched-at:' + collection;
379
472
  var displayTs = localStorage.getItem(lsKey) || state.fetchedAt;
380
473
  fetchedAtEl.textContent = displayTs
@@ -383,13 +476,11 @@
383
476
  }
384
477
 
385
478
  if (state.prs.length === 0) {
386
- var emptyMsg = collection === 'review-requests'
387
- ? 'No pull requests awaiting your review.'
388
- : 'You have no open pull requests.';
389
-
390
- if (!state.fetchedAt) {
391
- emptyMsg = 'Click refresh to fetch from GitHub.';
392
- }
479
+ var emptyLsKey = 'github-collection-fetched-at:' + collection;
480
+ var hasFetched = state.fetchedAt || localStorage.getItem(emptyLsKey);
481
+ var emptyMsg = hasFetched
482
+ ? (COLLECTION_EMPTY_MESSAGES[collection] || 'No pull requests found.')
483
+ : 'Click refresh to fetch from GitHub.';
393
484
 
394
485
  container.innerHTML =
395
486
  '<div class="recent-reviews-empty">' +
@@ -401,7 +492,7 @@
401
492
 
402
493
  var authorTh = collection === 'my-prs' ? '' : '<th>Author</th>';
403
494
 
404
- var tbodyId = collection === 'review-requests' ? 'review-requests-tbody' : 'my-prs-tbody';
495
+ var tbodyId = collection + '-tbody';
405
496
 
406
497
  container.innerHTML =
407
498
  '<table class="recent-reviews-table">' +
@@ -424,18 +515,28 @@
424
515
 
425
516
  /**
426
517
  * Load collection PRs from cached backend data.
427
- * @param {string} collection - 'review-requests' or 'my-prs'
518
+ * @param {string} collection - 'review-requests', 'team-reviews', or 'my-prs'
428
519
  * @param {string} containerId - DOM id of the container element
429
520
  * @param {Object} state - The collection state object
521
+ * @param {string} [team] - Optional validated `org/team` filter (team-reviews
522
+ * only). When set, requests the namespaced cache for that team.
523
+ * @param {number} [requestId] - Optional team-reviews request token (see
524
+ * beginTeamReviewsRequest). When provided, the response is discarded if a
525
+ * newer request has superseded it before this fetch resolved.
430
526
  */
431
- async function loadCollectionPrs(collection, containerId, state) {
527
+ async function loadCollectionPrs(collection, containerId, state, team, requestId) {
432
528
  var container = document.getElementById(containerId);
433
529
 
434
530
  try {
435
- var response = await fetch('/api/github/' + collection);
531
+ var url = '/api/github/' + collection + (team ? '?team=' + encodeURIComponent(team) : '');
532
+ var response = await fetch(url);
436
533
  if (!response.ok) throw new Error('Failed to fetch');
437
534
  var data = await response.json();
438
535
 
536
+ // Discard a response a newer team-reviews request has superseded, so a
537
+ // slow earlier load can't repaint the table with a stale team's PRs.
538
+ if (requestId != null && requestId !== state.activeRequest) return;
539
+
439
540
  state.loaded = true;
440
541
  state.prs = data.prs || [];
441
542
  state.fetchedAt = data.fetched_at;
@@ -443,6 +544,8 @@
443
544
  renderCollectionTable(container, state, collection);
444
545
  } catch (error) {
445
546
  console.error('Error loading ' + collection + ':', error);
547
+ // Don't paint the error over a view a newer request now owns.
548
+ if (requestId != null && requestId !== state.activeRequest) return;
446
549
  container.innerHTML =
447
550
  '<div class="recent-reviews-empty">' +
448
551
  '<p>Failed to load. Click refresh to try again.</p>' +
@@ -453,14 +556,18 @@
453
556
 
454
557
  /**
455
558
  * Refresh collection PRs by fetching fresh data from GitHub.
456
- * @param {string} collection - 'review-requests' or 'my-prs'
559
+ * @param {string} collection - 'review-requests', 'team-reviews', or 'my-prs'
457
560
  * @param {string} containerId - DOM id of the container element
458
561
  * @param {Object} state - The collection state object
562
+ * @param {string} [team] - Optional validated `org/team` filter (team-reviews
563
+ * only). When set, refreshes and caches under the namespaced key.
564
+ * @param {number} [requestId] - Optional team-reviews request token (see
565
+ * beginTeamReviewsRequest). When provided, the response is discarded if a
566
+ * newer request has superseded it before this fetch resolved.
459
567
  */
460
- async function refreshCollectionPrs(collection, containerId, state) {
568
+ async function refreshCollectionPrs(collection, containerId, state, team, requestId) {
461
569
  var container = document.getElementById(containerId);
462
- var btnId = collection === 'review-requests' ? 'refresh-review-requests' : 'refresh-my-prs';
463
- var btn = document.getElementById(btnId);
570
+ var btn = document.getElementById('refresh-' + collection);
464
571
 
465
572
  if (btn) btn.classList.add('refreshing');
466
573
 
@@ -470,7 +577,12 @@
470
577
  }
471
578
 
472
579
  try {
473
- var response = await fetch('/api/github/' + collection + '/refresh', { method: 'POST' });
580
+ var refreshUrl = '/api/github/' + collection + '/refresh' + (team ? '?team=' + encodeURIComponent(team) : '');
581
+ var response = await fetch(refreshUrl, { method: 'POST' });
582
+
583
+ // A newer team-reviews request has superseded this one; drop it before
584
+ // touching the 401 message, shared state, or the table.
585
+ if (requestId != null && requestId !== state.activeRequest) return;
474
586
 
475
587
  if (!response.ok) {
476
588
  var errData = await response.json().catch(function() { return {}; });
@@ -478,7 +590,7 @@
478
590
  container.innerHTML =
479
591
  '<div class="recent-reviews-empty">' +
480
592
  '<p>Configure a GitHub token to see ' +
481
- (collection === 'review-requests' ? 'review requests' : 'your pull requests') +
593
+ (COLLECTION_TOKEN_LABELS[collection] || 'pull requests') +
482
594
  '.</p>' +
483
595
  '</div>';
484
596
  container.classList.remove('recent-reviews-loading');
@@ -488,14 +600,26 @@
488
600
  }
489
601
 
490
602
  var data = await response.json();
603
+
604
+ // Re-check after the body parse: a newer request may have started while
605
+ // the response was streaming.
606
+ if (requestId != null && requestId !== state.activeRequest) return;
607
+
491
608
  state.prs = data.prs || [];
492
609
  state.fetchedAt = data.fetched_at;
493
610
  state.loaded = true;
611
+ // Note: this key is intentionally NOT per-team — unlike the DB cache key
612
+ // in src/routes/github-collections.js (which uses cacheKey()), the
613
+ // fetched-at timestamp is informational only. We accept a brief label
614
+ // mismatch when the user switches the team-reviews filter; the label
615
+ // updates once the next refresh completes (read site: renderCollectionTable).
494
616
  localStorage.setItem('github-collection-fetched-at:' + collection, new Date().toISOString());
495
617
 
496
618
  renderCollectionTable(container, state, collection);
497
619
  } catch (error) {
498
620
  console.error('Error refreshing ' + collection + ':', error);
621
+ // Don't repaint a view a newer request now owns.
622
+ if (requestId != null && requestId !== state.activeRequest) return;
499
623
  // If we had existing data, keep showing it
500
624
  if (state.prs.length > 0) {
501
625
  renderCollectionTable(container, state, collection);
@@ -511,6 +635,62 @@
511
635
  }
512
636
  }
513
637
 
638
+ /**
639
+ * Apply the Team Review Requests team filter from the input field.
640
+ * Validates client-side (UX only), persists the value, toggles the Clear
641
+ * affordance and inline hint, then reloads the team-reviews view for the
642
+ * selected team (empty input = all teams).
643
+ */
644
+ function applyTeamFilter() {
645
+ var input = document.getElementById('team-reviews-team-input');
646
+ if (!input) return;
647
+ var hint = document.getElementById('team-reviews-filter-hint');
648
+ var clearBtn = document.getElementById('team-reviews-filter-clear');
649
+ var value = input.value.trim();
650
+
651
+ // Non-empty input must match the org/team format; skip the fetch and show
652
+ // the inline hint on invalid input. Empty means "all teams" (valid).
653
+ if (value !== '' && !TEAM_SLUG_PATTERN.test(value)) {
654
+ input.classList.add('invalid');
655
+ input.setAttribute('aria-invalid', 'true');
656
+ if (hint) hint.hidden = false;
657
+ return;
658
+ }
659
+
660
+ input.classList.remove('invalid');
661
+ input.removeAttribute('aria-invalid');
662
+ if (hint) hint.hidden = true;
663
+
664
+ try {
665
+ if (value) {
666
+ localStorage.setItem(TEAM_FILTER_LS_KEY, value);
667
+ } else {
668
+ localStorage.removeItem(TEAM_FILTER_LS_KEY);
669
+ }
670
+ } catch (e) { /* localStorage unavailable; filter still applies for this view */ }
671
+
672
+ if (clearBtn) clearBtn.hidden = !value;
673
+
674
+ // Reset loaded state so we read the (namespaced) cache for the new view
675
+ // before refreshing live from GitHub.
676
+ teamReviewsState.loaded = false;
677
+ teamReviewsState.prs = [];
678
+ // Paint a placeholder so the previous team's rows don't linger while
679
+ // loadCollectionPrs / refreshCollectionPrs run asynchronously.
680
+ var container = document.getElementById('team-reviews-container');
681
+ if (container) {
682
+ container.innerHTML = '<div class="recent-reviews-loading">Loading...</div>';
683
+ }
684
+ var team = value || '';
685
+ // One token for this user action, threaded through both the cached GET and
686
+ // the live refresh, so a slower earlier request can't clobber this view.
687
+ var requestId = beginTeamReviewsRequest();
688
+ loadCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, team, requestId)
689
+ .then(function () {
690
+ refreshCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, team, requestId);
691
+ });
692
+ }
693
+
514
694
  /**
515
695
  * Fetch and display local review sessions (initial load).
516
696
  */
@@ -1610,6 +1790,17 @@
1610
1790
  ]
1611
1791
  });
1612
1792
 
1793
+ var teamReviewsSelection = new SelectionMode({
1794
+ tabId: 'team-reviews-tab',
1795
+ containerId: 'team-reviews-container',
1796
+ tbodyId: 'team-reviews-tbody',
1797
+ rowIdAttr: 'prUrl',
1798
+ actions: [
1799
+ { label: 'Open', className: 'btn-bulk-open', handler: handleBulkOpen },
1800
+ { label: 'Analyze', className: 'btn-bulk-analyze', handler: handleBulkAnalyze }
1801
+ ]
1802
+ });
1803
+
1613
1804
  var myPrsSelection = new SelectionMode({
1614
1805
  tabId: 'my-prs-tab',
1615
1806
  containerId: 'my-prs-container',
@@ -1621,6 +1812,13 @@
1621
1812
  ]
1622
1813
  });
1623
1814
 
1815
+ /** Map of collection name → its SelectionMode instance. */
1816
+ var collectionSelections = {
1817
+ 'review-requests': reviewRequestsSelection,
1818
+ 'team-reviews': teamReviewsSelection,
1819
+ 'my-prs': myPrsSelection
1820
+ };
1821
+
1624
1822
  async function handleBulkDeletePR(selectedIds, selectionInstance) {
1625
1823
  var count = selectedIds.size;
1626
1824
  selectionInstance.showConfirm('Delete ' + count + ' review' + (count === 1 ? '' : 's') + '?', async function () {
@@ -1762,6 +1960,19 @@
1762
1960
  return;
1763
1961
  }
1764
1962
 
1963
+ var refreshTeamReviewsBtn = event.target.closest('#refresh-team-reviews');
1964
+ if (refreshTeamReviewsBtn) {
1965
+ event.preventDefault();
1966
+ // Resync the controls to the applied filter before refreshing: unapplied
1967
+ // draft text in the input must not advertise a team different from the one
1968
+ // we actually fetch (which keys off the persisted value).
1969
+ var teamReviewsRefreshFilter = getStoredTeamFilter();
1970
+ syncTeamFilterControls(teamReviewsRefreshFilter);
1971
+ var teamReviewsRefreshId = beginTeamReviewsRequest();
1972
+ refreshCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, teamReviewsRefreshFilter, teamReviewsRefreshId);
1973
+ return;
1974
+ }
1975
+
1765
1976
  var refreshMyPrsBtn = event.target.closest('#refresh-my-prs');
1766
1977
  if (refreshMyPrsBtn) {
1767
1978
  event.preventDefault();
@@ -1774,7 +1985,7 @@
1774
1985
  if (selectToggle) {
1775
1986
  event.preventDefault();
1776
1987
  var tabId = selectToggle.dataset.selectionTab;
1777
- var instances = { 'pr-tab': prSelection, 'local-tab': localSelection, 'review-requests-tab': reviewRequestsSelection, 'my-prs-tab': myPrsSelection };
1988
+ var instances = { 'pr-tab': prSelection, 'local-tab': localSelection, 'review-requests-tab': reviewRequestsSelection, 'team-reviews-tab': teamReviewsSelection, 'my-prs-tab': myPrsSelection };
1778
1989
  var instance = instances[tabId];
1779
1990
  if (instance) instance.toggle();
1780
1991
  return;
@@ -1851,6 +2062,18 @@
1851
2062
  }
1852
2063
  refreshCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
1853
2064
  }
2065
+ if (tabId === 'team-reviews-tab') {
2066
+ var teamFilter = getStoredTeamFilter();
2067
+ // Re-entering the tab preserves any unapplied draft text in the input
2068
+ // (switchTab does not rebuild the DOM). Resync the controls to the
2069
+ // applied filter so the visible team matches the one we reload.
2070
+ syncTeamFilterControls(teamFilter);
2071
+ var teamReviewsTabRequestId = beginTeamReviewsRequest();
2072
+ if (!teamReviewsState.loaded) {
2073
+ await loadCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, teamFilter, teamReviewsTabRequestId);
2074
+ }
2075
+ refreshCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, teamFilter, teamReviewsTabRequestId);
2076
+ }
1854
2077
  if (tabId === 'my-prs-tab') {
1855
2078
  if (!myPrsState.loaded) {
1856
2079
  await loadCollectionPrs('my-prs', 'my-prs-container', myPrsState);
@@ -1894,6 +2117,12 @@
1894
2117
  loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState)
1895
2118
  .then(function () { refreshCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState); });
1896
2119
  }
2120
+ if (savedTab === 'team-reviews-tab') {
2121
+ var initialTeamFilter = getStoredTeamFilter();
2122
+ var initialTeamRequestId = beginTeamReviewsRequest();
2123
+ loadCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, initialTeamFilter, initialTeamRequestId)
2124
+ .then(function () { refreshCollectionPrs('team-reviews', 'team-reviews-container', teamReviewsState, initialTeamFilter, initialTeamRequestId); });
2125
+ }
1897
2126
  if (savedTab === 'my-prs-tab') {
1898
2127
  loadCollectionPrs('my-prs', 'my-prs-container', myPrsState)
1899
2128
  .then(function () { refreshCollectionPrs('my-prs', 'my-prs-container', myPrsState); });
@@ -1922,6 +2151,37 @@
1922
2151
  browseBtn.addEventListener('click', handleBrowseLocal);
1923
2152
  }
1924
2153
 
2154
+ // Set up Team Review Requests team-filter handlers and restore the
2155
+ // persisted value.
2156
+ const teamFilterInput = document.getElementById('team-reviews-team-input');
2157
+ const teamFilterForm = document.getElementById('team-reviews-filter');
2158
+ const teamFilterClearBtn = document.getElementById('team-reviews-filter-clear');
2159
+ if (teamFilterInput) {
2160
+ // Restore the persisted filter into the controls on initial load (same
2161
+ // resync the reload paths use, so the displayed team always matches the
2162
+ // applied filter).
2163
+ syncTeamFilterControls(getStoredTeamFilter());
2164
+ // Clear the invalid state / inline hint as the user edits.
2165
+ teamFilterInput.addEventListener('input', function () {
2166
+ teamFilterInput.classList.remove('invalid');
2167
+ teamFilterInput.removeAttribute('aria-invalid');
2168
+ const hint = document.getElementById('team-reviews-filter-hint');
2169
+ if (hint) hint.hidden = true;
2170
+ });
2171
+ }
2172
+ if (teamFilterForm) {
2173
+ teamFilterForm.addEventListener('submit', function (event) {
2174
+ event.preventDefault();
2175
+ applyTeamFilter();
2176
+ });
2177
+ }
2178
+ if (teamFilterClearBtn) {
2179
+ teamFilterClearBtn.addEventListener('click', function () {
2180
+ if (teamFilterInput) teamFilterInput.value = '';
2181
+ applyTeamFilter();
2182
+ });
2183
+ }
2184
+
1925
2185
  // Set up PR Analyze button handler
1926
2186
  const analyzeReviewBtn = document.getElementById('analyze-review-btn');
1927
2187
  if (analyzeReviewBtn) {
@@ -1985,6 +2245,19 @@
1985
2245
  rrHeader.insertBefore(rrBtn, rrHeader.firstChild);
1986
2246
  }
1987
2247
 
2248
+ // Team Reviews tab: add to existing header. Insert before the fetched-at
2249
+ // label rather than as firstChild — the leading `.team-filter-group` carries
2250
+ // `margin-right: auto`, so a firstChild button would be shoved to the far
2251
+ // left. Placing it here keeps Select grouped with fetched-at + refresh on
2252
+ // the right, matching the other collection tabs.
2253
+ var trHeader = document.querySelector('#team-reviews-tab .tab-pane-header');
2254
+ if (trHeader) {
2255
+ var trBtn = createSelectButton('team-reviews-tab');
2256
+ teamReviewsSelection._toggleBtn = trBtn;
2257
+ var trFetchedAt = document.getElementById('team-reviews-fetched-at');
2258
+ trHeader.insertBefore(trBtn, trFetchedAt || trHeader.firstChild);
2259
+ }
2260
+
1988
2261
  // My PRs tab: add to existing header
1989
2262
  var mpHeader = document.querySelector('#my-prs-tab .tab-pane-header');
1990
2263
  if (mpHeader) {
@@ -1404,6 +1404,9 @@ class LocalManager {
1404
1404
  const diffContent = data.diff || '';
1405
1405
  const stats = data.stats || {};
1406
1406
  const generatedFiles = new Set(data.generated_files || []);
1407
+ // Server-computed per-file hunk hashes (computed from the canonical
1408
+ // diff so they remain stable across whitespace-filtered renders).
1409
+ const hunkHashesByFile = data.hunk_hashes_by_file || {};
1407
1410
 
1408
1411
  if (!diffContent) {
1409
1412
  const diffContainer = document.getElementById('diff-container');
@@ -1468,6 +1471,9 @@ class LocalManager {
1468
1471
  insertions: additions,
1469
1472
  deletions: deletions,
1470
1473
  generated: isGenerated,
1474
+ // Pass through the server-computed canonical hunk hashes; renderPatch
1475
+ // requires these to anchor persisted summaries (no client-side fallback).
1476
+ hunk_hashes: hunkHashesByFile[fileName] || null,
1471
1477
  status: patch.includes('new file mode') ? 'added' :
1472
1478
  patch.includes('deleted file mode') ? 'removed' : 'modified'
1473
1479
  });