@in-the-loop-labs/pair-review 3.5.0 → 3.5.2

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 CHANGED
@@ -493,7 +493,7 @@ Configure your preferred models in `providers.pi.models` — see [AI Provider Co
493
493
  }
494
494
  ```
495
495
 
496
- Available chat provider IDs: `pi`, `claude`, `codex`, `copilot-acp`, `gemini-acp`, `opencode-acp`, `cursor-acp`. Each supports `command`, `args` (replaces defaults), `extra_args` (appends), and `env` overrides.
496
+ Available chat provider IDs: `pi`, `claude`, `codex`, `copilot-acp`, `gemini-acp`, `opencode-acp`, `cursor-acp`. Each supports `command`, `args` (replaces defaults), `extra_args` (appends), and `env` overrides. Codex chat also supports `sandbox`: use `workspace-write` by default, or `read-only` for discussion-only sessions.
497
497
 
498
498
  **Keyboard shortcut:** Press `p` then `c` to toggle the chat panel.
499
499
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.5.0",
3
+ "version": "3.5.2",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.5.0",
3
+ "version": "3.5.2",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "3.5.0",
3
+ "version": "3.5.2",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
package/public/index.html CHANGED
@@ -960,6 +960,65 @@
960
960
  animation: spin 0.8s linear infinite;
961
961
  }
962
962
 
963
+ /* Team Review Requests: filter-by-team control */
964
+ .team-filter-group {
965
+ display: flex;
966
+ align-items: center;
967
+ gap: 8px;
968
+ /* Push the filter to the left; fetched-at + refresh stay right. */
969
+ margin-right: auto;
970
+ }
971
+
972
+ .team-filter {
973
+ display: flex;
974
+ align-items: center;
975
+ gap: 4px;
976
+ }
977
+
978
+ .team-filter-input {
979
+ width: 160px;
980
+ height: 32px;
981
+ padding: 0 8px;
982
+ font-size: 13px;
983
+ background: var(--color-bg-primary);
984
+ border: 1px solid var(--color-border-primary);
985
+ border-radius: var(--radius-md);
986
+ color: var(--color-text-primary);
987
+ }
988
+
989
+ .team-filter-input:focus {
990
+ outline: none;
991
+ border-color: var(--ai-primary);
992
+ }
993
+
994
+ .team-filter-input.invalid {
995
+ border-color: var(--color-danger);
996
+ }
997
+
998
+ .team-filter-apply,
999
+ .team-filter-clear {
1000
+ height: 32px;
1001
+ padding: 0 10px;
1002
+ font-size: 13px;
1003
+ background: transparent;
1004
+ border: 1px solid var(--color-border-primary);
1005
+ border-radius: var(--radius-md);
1006
+ color: var(--color-text-tertiary);
1007
+ cursor: pointer;
1008
+ transition: all var(--transition-fast);
1009
+ }
1010
+
1011
+ .team-filter-apply:hover,
1012
+ .team-filter-clear:hover {
1013
+ background: var(--color-bg-secondary);
1014
+ color: var(--color-text-primary);
1015
+ }
1016
+
1017
+ .team-filter-hint {
1018
+ font-size: 12px;
1019
+ color: var(--color-danger);
1020
+ }
1021
+
963
1022
  .collection-pr-row {
964
1023
  cursor: pointer;
965
1024
  }
@@ -1343,6 +1402,7 @@
1343
1402
  <div class="tab-bar" id="unified-tab-bar">
1344
1403
  <button class="tab-btn active" data-tab="pr-tab" type="button">Pull Requests</button>
1345
1404
  <button class="tab-btn" data-tab="review-requests-tab" type="button">My Review Requests</button>
1405
+ <button class="tab-btn" data-tab="team-reviews-tab" type="button">Team Review Requests</button>
1346
1406
  <button class="tab-btn" data-tab="my-prs-tab" type="button">My PRs</button>
1347
1407
  <span class="tab-divider"></span>
1348
1408
  <button class="tab-btn" data-tab="local-tab" type="button">Local Reviews</button>
@@ -1392,6 +1452,36 @@
1392
1452
  </div>
1393
1453
  </div>
1394
1454
 
1455
+ <!-- Team Review Requests Tab -->
1456
+ <div class="tab-pane" id="team-reviews-tab">
1457
+ <div class="tab-pane-header">
1458
+ <div class="team-filter-group">
1459
+ <form class="team-filter" id="team-reviews-filter">
1460
+ <input
1461
+ type="text"
1462
+ class="team-filter-input"
1463
+ id="team-reviews-team-input"
1464
+ placeholder="org/team"
1465
+ autocomplete="off"
1466
+ spellcheck="false"
1467
+ aria-label="Filter by team (org/team)"
1468
+ aria-describedby="team-reviews-filter-hint"
1469
+ >
1470
+ <button type="submit" class="team-filter-apply" id="team-reviews-filter-apply" title="Filter by this team">Filter</button>
1471
+ <button type="button" class="team-filter-clear" id="team-reviews-filter-clear" title="Show all teams" hidden>Clear</button>
1472
+ </form>
1473
+ <span class="team-filter-hint" id="team-reviews-filter-hint" role="alert" hidden>Use the form org/team.</span>
1474
+ </div>
1475
+ <span class="fetched-at-label" id="team-reviews-fetched-at"></span>
1476
+ <button class="btn-refresh" id="refresh-team-reviews" title="Refresh from GitHub">
1477
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></svg>
1478
+ </button>
1479
+ </div>
1480
+ <div id="team-reviews-container" class="recent-reviews-loading">
1481
+ Loading...
1482
+ </div>
1483
+ </div>
1484
+
1395
1485
  <!-- My PRs Tab -->
1396
1486
  <div class="tab-pane" id="my-prs-tab">
1397
1487
  <div class="tab-pane-header">
@@ -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) {