@d3lm/pr-stats 0.2.21 → 0.2.23

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/dist/tui-app.mjs CHANGED
@@ -136,10 +136,10 @@ import { createRoot } from "@opentui/react";
136
136
 
137
137
  // src/tui/App.tsx
138
138
  import { useKeyboard, useRenderer as useRenderer3, useTerminalDimensions as useTerminalDimensions3 } from "@opentui/react";
139
- import { useEffect as useEffect8, useMemo as useMemo2, useReducer, useRef as useRef6, useState as useState5 } from "react";
139
+ import { useEffect as useEffect8, useMemo as useMemo2, useReducer, useRef as useRef8, useState as useState6 } from "react";
140
140
 
141
- // src/settings.ts
142
- import { readFileSync as readFileSync3, rmSync as rmSync2 } from "node:fs";
141
+ // src/mentions.ts
142
+ import { readFileSync as readFileSync3 } from "node:fs";
143
143
  import { join as join3 } from "node:path";
144
144
 
145
145
  // src/cache.ts
@@ -166,7 +166,7 @@ function cacheDir() {
166
166
  function prKey(repo, number) {
167
167
  return `${repo}#${number}`;
168
168
  }
169
- var CACHE_FILES = ["details", "sizes", "user", "snapshot"];
169
+ var CACHE_FILES = ["details", "sizes", "mentions", "user", "snapshot", "mention-baseline"];
170
170
  function clearCache() {
171
171
  if (!enabled) {
172
172
  return false;
@@ -283,6 +283,9 @@ function percentile(sorted, percent) {
283
283
  }
284
284
 
285
285
  // src/snooze.ts
286
+ function snoozeMatches(snooze, target) {
287
+ return snooze.kind === target.kind && snooze.ref === target.ref;
288
+ }
286
289
  var DEFAULT_SNOOZE_DURATION = "30m";
287
290
  var MINUTE_MS = 6e4;
288
291
  var HOUR_MS = 60 * MINUTE_MS;
@@ -315,20 +318,33 @@ function formatWakeTime(until, now = Date.now()) {
315
318
  function snoozesFile() {
316
319
  return join2(cacheDir(), "snoozes.json");
317
320
  }
318
- function reviveSnooze(ref, stored) {
321
+ function parseStoredKey(key) {
322
+ for (const kind of ["review", "mention"]) {
323
+ if (key.startsWith(`${kind}:`)) {
324
+ return { kind, ref: key.slice(kind.length + 1) };
325
+ }
326
+ }
327
+ return { kind: "review", ref: key };
328
+ }
329
+ function reviveSnooze(key, stored) {
319
330
  if (typeof stored !== "object" || stored === null) {
320
331
  return null;
321
332
  }
322
- const { until, requestedAt } = stored;
323
- if (typeof until !== "string" || typeof requestedAt !== "string") {
333
+ const { until, at, requestedAt, ids } = stored;
334
+ const askedAt = at ?? requestedAt;
335
+ if (typeof until !== "string" || typeof askedAt !== "string") {
324
336
  return null;
325
337
  }
326
338
  const untilMs = Date.parse(until);
327
- const requestedAtMs = Date.parse(requestedAt);
328
- if (Number.isNaN(untilMs) || Number.isNaN(requestedAtMs)) {
339
+ const atMs = Date.parse(askedAt);
340
+ if (Number.isNaN(untilMs) || Number.isNaN(atMs)) {
329
341
  return null;
330
342
  }
331
- return { ref, until: untilMs, requestedAt: requestedAtMs };
343
+ const snooze = { ...parseStoredKey(key), until: untilMs, at: atMs };
344
+ if (Array.isArray(ids)) {
345
+ snooze.ids = ids.filter((id) => typeof id === "string");
346
+ }
347
+ return snooze;
332
348
  }
333
349
  function readSnoozes() {
334
350
  if (!cacheEnabled()) {
@@ -344,8 +360,8 @@ function readSnoozes() {
344
360
  return [];
345
361
  }
346
362
  const snoozes2 = [];
347
- for (const [ref, stored] of Object.entries(parsed)) {
348
- const snooze = reviveSnooze(ref, stored);
363
+ for (const [key, stored] of Object.entries(parsed)) {
364
+ const snooze = reviveSnooze(key, stored);
349
365
  if (snooze !== null) {
350
366
  snoozes2.push(snooze);
351
367
  }
@@ -358,25 +374,25 @@ function writeSnoozes(snoozes2) {
358
374
  }
359
375
  const stored = {};
360
376
  for (const snooze of snoozes2) {
361
- stored[snooze.ref] = {
377
+ stored[`${snooze.kind}:${snooze.ref}`] = {
362
378
  until: new Date(snooze.until).toISOString(),
363
- requestedAt: new Date(snooze.requestedAt).toISOString()
379
+ at: new Date(snooze.at).toISOString(),
380
+ ...snooze.ids === void 0 ? {} : { ids: [...snooze.ids] }
364
381
  };
365
382
  }
366
383
  writeFileAtomic(snoozesFile(), `${JSON.stringify(stored, null, 2)}
367
384
  `);
368
385
  return true;
369
386
  }
370
- function activeSnooze(snoozes2, ref, requestedAt, now) {
371
- return snoozes2.find(
372
- (snooze) => snooze.ref === ref && snooze.until > now && requestedAt.getTime() <= snooze.requestedAt
373
- );
387
+ function findSnooze(snoozes2, target, now, covers) {
388
+ return snoozes2.find((snooze) => snoozeMatches(snooze, target) && snooze.until > now && covers(snooze));
374
389
  }
375
- function splitSnoozed(entries, snoozes2, now) {
390
+ function partitionSnoozed(kind, entries, snoozes2, now, covers) {
376
391
  const awaiting = [];
377
392
  const snoozed = [];
378
393
  for (const entry of entries) {
379
- const snooze = activeSnooze(snoozes2, prKey(entry.pr.repo, entry.pr.number), entry.requestedAt, now);
394
+ const target = { kind, ref: prKey(entry.pr.repo, entry.pr.number) };
395
+ const snooze = findSnooze(snoozes2, target, now, (candidate) => covers(candidate, entry));
380
396
  if (snooze === void 0) {
381
397
  awaiting.push(entry);
382
398
  } else {
@@ -386,6 +402,9 @@ function splitSnoozed(entries, snoozes2, now) {
386
402
  snoozed.sort((a, b) => a.until - b.until);
387
403
  return { awaiting, snoozed };
388
404
  }
405
+ function splitSnoozed(entries, snoozes2, now) {
406
+ return partitionSnoozed("review", entries, snoozes2, now, (snooze, entry) => entry.requestedAt.getTime() <= snooze.at);
407
+ }
389
408
  function nextWakeUp(snoozes2) {
390
409
  if (snoozes2.length === 0) {
391
410
  return null;
@@ -406,12 +425,181 @@ function wokenPrs(due, results) {
406
425
  }
407
426
  }
408
427
  return due.flatMap((snooze) => {
428
+ if (snooze.kind !== "review") {
429
+ return [];
430
+ }
409
431
  const entry = pending.get(snooze.ref);
410
- return entry !== void 0 && entry.requestedAt <= snooze.requestedAt ? [entry.pr] : [];
432
+ return entry !== void 0 && entry.requestedAt <= snooze.at ? [entry.pr] : [];
411
433
  });
412
434
  }
413
435
 
436
+ // src/mentions.ts
437
+ function emptyMentionReads() {
438
+ return { seed: null, reads: /* @__PURE__ */ new Map() };
439
+ }
440
+ function mentionIdsOf(entry) {
441
+ return [...(entry.mentions ?? []).map((mention) => mention.id), ...entry.earlier];
442
+ }
443
+ function mentionItems(entries) {
444
+ const items = [];
445
+ for (const entry of entries) {
446
+ if (entry.mentions === null || entry.mentions.length === 0) {
447
+ continue;
448
+ }
449
+ items.push({
450
+ pr: entry.pr,
451
+ mentionedAt: Math.max(...entry.mentions.map((mention) => mention.at.getTime())),
452
+ mentions: entry.mentions,
453
+ ids: mentionIdsOf(entry)
454
+ });
455
+ }
456
+ return items;
457
+ }
458
+ function markOf(item) {
459
+ return { at: item.mentionedAt, ids: item.ids };
460
+ }
461
+ function hasNewMention(item, mark) {
462
+ return item.mentions.some((mention) => mention.at.getTime() > mark.at && !mark.ids.includes(mention.id));
463
+ }
464
+ function isUnreadMention(reads, item) {
465
+ const mark = reads.reads.get(prKey(item.pr.repo, item.pr.number));
466
+ if (mark === null) {
467
+ return true;
468
+ }
469
+ if (mark !== void 0) {
470
+ return hasNewMention(item, mark);
471
+ }
472
+ return reads.seed !== null && hasNewMention(item, reads.seed);
473
+ }
474
+ function seedMentionReads(reads, seed) {
475
+ return reads.seed === null ? { ...reads, seed } : reads;
476
+ }
477
+ function unseedMentionReads(reads) {
478
+ return reads.seed === null ? reads : { ...reads, seed: null };
479
+ }
480
+ function markMentionRead(reads, ref, mark) {
481
+ return { ...reads, reads: new Map(reads.reads).set(ref, mark) };
482
+ }
483
+ function markMentionUnread(reads, ref) {
484
+ return { ...reads, reads: new Map(reads.reads).set(ref, null) };
485
+ }
486
+ function snoozeMark(snooze) {
487
+ return { at: snooze.at, ids: snooze.ids ?? [] };
488
+ }
489
+ function splitMentions(items, reads, snoozes2, now) {
490
+ const read = [];
491
+ const pending = [];
492
+ for (const item of items) {
493
+ (isUnreadMention(reads, item) ? pending : read).push(item);
494
+ }
495
+ const { awaiting, snoozed } = partitionSnoozed(
496
+ "mention",
497
+ pending,
498
+ snoozes2,
499
+ now,
500
+ (snooze, item) => !hasNewMention(item, snoozeMark(snooze))
501
+ );
502
+ return { unread: awaiting.toSorted(byNewestMention), snoozed, read: read.toSorted(byNewestMention) };
503
+ }
504
+ function byNewestMention(a, b) {
505
+ return b.mentionedAt - a.mentionedAt;
506
+ }
507
+ function wokenMentions(due, entries, reads) {
508
+ const items = /* @__PURE__ */ new Map();
509
+ for (const item of mentionItems(entries)) {
510
+ items.set(prKey(item.pr.repo, item.pr.number), item);
511
+ }
512
+ return due.flatMap((snooze) => {
513
+ if (snooze.kind !== "mention") {
514
+ return [];
515
+ }
516
+ const item = items.get(snooze.ref);
517
+ return item !== void 0 && !hasNewMention(item, snoozeMark(snooze)) && isUnreadMention(reads, item) ? [item.pr] : [];
518
+ });
519
+ }
520
+ function mentionReadsFile() {
521
+ return join3(cacheDir(), "mention-reads.json");
522
+ }
523
+ function reviveMark(stored) {
524
+ if (typeof stored !== "object" || stored === null || Array.isArray(stored)) {
525
+ return void 0;
526
+ }
527
+ const { at, ids } = stored;
528
+ if (typeof at !== "string" || !Array.isArray(ids)) {
529
+ return void 0;
530
+ }
531
+ const ms = Date.parse(at);
532
+ return Number.isNaN(ms) ? void 0 : { at: ms, ids: ids.filter((id) => typeof id === "string") };
533
+ }
534
+ function reviveReads(stored) {
535
+ if (typeof stored !== "object" || stored === null || Array.isArray(stored)) {
536
+ return void 0;
537
+ }
538
+ const { seed, reads: marks } = stored;
539
+ const reads = /* @__PURE__ */ new Map();
540
+ if (typeof marks === "object" && marks !== null && !Array.isArray(marks)) {
541
+ for (const [ref, mark] of Object.entries(marks)) {
542
+ if (mark === null) {
543
+ reads.set(ref, null);
544
+ } else {
545
+ const revived = reviveMark(mark);
546
+ if (revived !== void 0) {
547
+ reads.set(ref, revived);
548
+ }
549
+ }
550
+ }
551
+ }
552
+ return { seed: reviveMark(seed) ?? null, reads };
553
+ }
554
+ function readMentionReads() {
555
+ const all = /* @__PURE__ */ new Map();
556
+ if (!cacheEnabled()) {
557
+ return all;
558
+ }
559
+ let parsed;
560
+ try {
561
+ parsed = JSON.parse(readFileSync3(mentionReadsFile(), "utf8"));
562
+ } catch {
563
+ return all;
564
+ }
565
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
566
+ return all;
567
+ }
568
+ const { users } = parsed;
569
+ if (typeof users !== "object" || users === null || Array.isArray(users)) {
570
+ return all;
571
+ }
572
+ for (const [login, stored] of Object.entries(users)) {
573
+ const reads = reviveReads(stored);
574
+ if (reads !== void 0) {
575
+ all.set(login.toLowerCase(), reads);
576
+ }
577
+ }
578
+ return all;
579
+ }
580
+ function storeMark(mark) {
581
+ return { at: new Date(mark.at).toISOString(), ids: [...mark.ids] };
582
+ }
583
+ function writeMentionReads(all) {
584
+ if (!cacheEnabled()) {
585
+ return false;
586
+ }
587
+ const stored = { users: {} };
588
+ for (const [login, reads] of all) {
589
+ const marks = {};
590
+ for (const [ref, mark] of reads.reads) {
591
+ marks[ref] = mark === null ? null : storeMark(mark);
592
+ }
593
+ stored.users[login] = { seed: reads.seed === null ? null : storeMark(reads.seed), reads: marks };
594
+ }
595
+ writeFileAtomic(mentionReadsFile(), `${JSON.stringify(stored, null, 2)}
596
+ `);
597
+ return true;
598
+ }
599
+
414
600
  // src/settings.ts
601
+ import { readFileSync as readFileSync4, rmSync as rmSync2 } from "node:fs";
602
+ import { join as join4 } from "node:path";
415
603
  var NOTIFY_CHANNELS = ["auto", "terminal", "command", "bell"];
416
604
  var DEFAULT_RELOAD_INTERVAL = "10m";
417
605
  var MAX_RELOAD_INTERVAL_MS = 24 * 60 * 60 * 1e3;
@@ -431,7 +619,7 @@ function parseReloadInterval(input) {
431
619
  return ms;
432
620
  }
433
621
  function settingsFile() {
434
- return join3(cacheDir(), "settings.json");
622
+ return join4(cacheDir(), "settings.json");
435
623
  }
436
624
  var current = {};
437
625
  function writeCurrent() {
@@ -449,7 +637,7 @@ function loadSettings() {
449
637
  }
450
638
  let text;
451
639
  try {
452
- text = readFileSync3(settingsFile(), "utf8");
640
+ text = readFileSync4(settingsFile(), "utf8");
453
641
  } catch {
454
642
  return current;
455
643
  }
@@ -480,6 +668,12 @@ function loadSettings() {
480
668
  if (settings.notifications !== void 0 && typeof settings.notifications !== "boolean") {
481
669
  throw new CliError(`"notifications" in ${settingsFile()} must be true or false`);
482
670
  }
671
+ if (settings.trackMentions !== void 0 && typeof settings.trackMentions !== "boolean") {
672
+ throw new CliError(`"trackMentions" in ${settingsFile()} must be true or false`);
673
+ }
674
+ if (settings.notifyMentions !== void 0 && typeof settings.notifyMentions !== "boolean") {
675
+ throw new CliError(`"notifyMentions" in ${settingsFile()} must be true or false`);
676
+ }
483
677
  if (settings.notifyChannel !== void 0 && !NOTIFY_CHANNELS.includes(settings.notifyChannel)) {
484
678
  throw new CliError(`"notifyChannel" in ${settingsFile()} must be "auto", "terminal", "command", or "bell"`);
485
679
  }
@@ -511,6 +705,14 @@ function saveNotifications(on) {
511
705
  current = { ...current, notifications: on };
512
706
  return writeCurrent();
513
707
  }
708
+ function saveTrackMentions(on) {
709
+ current = { ...current, trackMentions: on };
710
+ return writeCurrent();
711
+ }
712
+ function saveNotifyMentions(on) {
713
+ current = { ...current, notifyMentions: on };
714
+ return writeCurrent();
715
+ }
514
716
  function saveNotifyChannel(channel) {
515
717
  current = { ...current, notifyChannel: channel };
516
718
  return writeCurrent();
@@ -544,6 +746,124 @@ function applySettings(values, explicit) {
544
746
  return settings;
545
747
  }
546
748
 
749
+ // src/tui/state/browse.ts
750
+ var TABS = ["1 Awaiting you", "2 Your PRs", "3 Reviews", "4 PR size", "5 Comments"];
751
+ var PENDING_SUB_TABS = [
752
+ { key: "pending", label: "Awaiting review" },
753
+ { key: "reviewed", label: "Reviewed" },
754
+ { key: "mentions", label: "Mentions" }
755
+ ];
756
+ var AUTHORED_SUB_TABS = [
757
+ { key: "open", label: "Open" },
758
+ { key: "merged", label: "Merged & closed" }
759
+ ];
760
+ function cycledPendingSubTab(current2, delta) {
761
+ const index = PENDING_SUB_TABS.findIndex((entry) => entry.key === current2);
762
+ return PENDING_SUB_TABS[(index + PENDING_SUB_TABS.length + delta) % PENDING_SUB_TABS.length];
763
+ }
764
+ var initialBrowseState = {
765
+ tab: 0,
766
+ pendingTab: "pending",
767
+ authoredTab: "open",
768
+ scopes: {
769
+ pending: { view: "list" },
770
+ reviewed: { view: "list" },
771
+ mentions: { view: "list" },
772
+ open: { view: "list" },
773
+ review: { view: "list" },
774
+ size: { view: "list" },
775
+ comment: { view: "list" },
776
+ merged: { view: "list" }
777
+ },
778
+ repoCursors: { pending: 0, reviewed: 0, mentions: 0, open: 0, review: 0, size: 0, comment: 0, merged: 0 },
779
+ rowCursors: { pending: 0, reviewed: 0, mentions: 0, open: 0 },
780
+ grouped: { pending: false, reviewed: false, mentions: false, open: false },
781
+ expanded: { review: false, size: false, comment: false, merged: false }
782
+ };
783
+ function activeQueueTab(state) {
784
+ if (state.tab === 0) {
785
+ return state.pendingTab;
786
+ }
787
+ if (state.tab === 1 && state.authoredTab === "open") {
788
+ return "open";
789
+ }
790
+ return null;
791
+ }
792
+ function dropVanishedRepo(scope, repos) {
793
+ if (scope.view === "detail" && scope.repo !== null && !repos.some((option) => option.repo === scope.repo)) {
794
+ return { view: "list" };
795
+ }
796
+ return scope;
797
+ }
798
+ function cycled(previous, delta, count2) {
799
+ return (Math.min(previous, count2 - 1) + count2 + delta) % count2;
800
+ }
801
+ function browseReducer(state, action) {
802
+ switch (action.type) {
803
+ case "tabSelected": {
804
+ return { ...state, tab: action.tab };
805
+ }
806
+ case "tabCycled": {
807
+ return { ...state, tab: (state.tab + TABS.length + action.delta) % TABS.length };
808
+ }
809
+ case "subTabCycled": {
810
+ if (state.tab === 0) {
811
+ return { ...state, pendingTab: cycledPendingSubTab(state.pendingTab, action.delta).key };
812
+ }
813
+ if (state.tab === 1) {
814
+ return { ...state, authoredTab: state.authoredTab === "open" ? "merged" : "open" };
815
+ }
816
+ return state;
817
+ }
818
+ case "repoCursorMoved": {
819
+ return {
820
+ ...state,
821
+ repoCursors: {
822
+ ...state.repoCursors,
823
+ [action.tab]: cycled(state.repoCursors[action.tab], action.delta, action.count)
824
+ }
825
+ };
826
+ }
827
+ case "rowCursorMoved": {
828
+ return {
829
+ ...state,
830
+ rowCursors: {
831
+ ...state.rowCursors,
832
+ [action.tab]: cycled(state.rowCursors[action.tab], action.delta, action.count)
833
+ }
834
+ };
835
+ }
836
+ case "repoOpened": {
837
+ return { ...state, scopes: { ...state.scopes, [action.tab]: { view: "detail", repo: action.repo } } };
838
+ }
839
+ case "pickerReturned": {
840
+ return { ...state, scopes: { ...state.scopes, [action.tab]: { view: "list" } } };
841
+ }
842
+ case "groupingToggled": {
843
+ return { ...state, grouped: { ...state.grouped, [action.tab]: !state.grouped[action.tab] } };
844
+ }
845
+ case "expandToggled": {
846
+ return { ...state, expanded: { ...state.expanded, [action.tab]: !state.expanded[action.tab] } };
847
+ }
848
+ case "dataLoaded": {
849
+ return {
850
+ ...state,
851
+ scopes: {
852
+ pending: dropVanishedRepo(state.scopes.pending, action.repos.pending),
853
+ reviewed: dropVanishedRepo(state.scopes.reviewed, action.repos.reviewed),
854
+ mentions: dropVanishedRepo(state.scopes.mentions, action.repos.mentions),
855
+ open: dropVanishedRepo(state.scopes.open, action.repos.open),
856
+ review: dropVanishedRepo(state.scopes.review, action.repos.review),
857
+ size: dropVanishedRepo(state.scopes.size, action.repos.size),
858
+ comment: dropVanishedRepo(state.scopes.comment, action.repos.comment),
859
+ merged: dropVanishedRepo(state.scopes.merged, action.repos.merged)
860
+ }
861
+ };
862
+ }
863
+ }
864
+ return state;
865
+ }
866
+
547
867
  // src/tui/theme.ts
548
868
  var BASE = {
549
869
  bg: "#1e1e1e",
@@ -837,26 +1157,23 @@ function durationHours(start, end) {
837
1157
  }
838
1158
 
839
1159
  // src/compute.ts
840
- function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Date() } = {}) {
841
- const reviewed = [];
1160
+ function pendingRequests(results) {
842
1161
  const pending = [];
843
1162
  for (const result of results) {
844
- if (result.kind === "reviewed") {
845
- reviewed.push({
846
- pr: result.pr,
847
- requestedAt: result.requestedAt,
848
- reviewedAt: result.reviewedAt,
849
- hours: durationHours(result.requestedAt, result.reviewedAt),
850
- verdict: result.verdict,
851
- lines: result.lines
852
- });
853
- } else if (result.kind === "pending" && result.pr.state === "open") {
854
- pending.push({ pr: result.pr, requestedAt: result.requestedAt, hours: durationHours(result.requestedAt, now) });
1163
+ if (result.kind === "pending" && result.pr.state === "open") {
1164
+ pending.push({ pr: result.pr, requestedAt: result.requestedAt });
855
1165
  }
856
1166
  }
857
- pending.sort((a, b) => a.requestedAt.getTime() - b.requestedAt.getTime());
858
- const pendingKeys = new Set(pending.map((entry) => `${entry.pr.repo}#${entry.pr.number}`));
859
- const latestReviews = /* @__PURE__ */ new Map();
1167
+ return pending.toSorted((a, b) => a.requestedAt.getTime() - b.requestedAt.getTime());
1168
+ }
1169
+ function latestReviews(results) {
1170
+ const pendingKeys = /* @__PURE__ */ new Set();
1171
+ for (const result of results) {
1172
+ if (result.kind === "pending" && result.pr.state === "open") {
1173
+ pendingKeys.add(`${result.pr.repo}#${result.pr.number}`);
1174
+ }
1175
+ }
1176
+ const latest = /* @__PURE__ */ new Map();
860
1177
  for (const result of results) {
861
1178
  if (result.kind !== "reviewed" && result.kind !== "unrequested" || result.pr.state !== "open") {
862
1179
  continue;
@@ -865,14 +1182,33 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
865
1182
  if (pendingKeys.has(key)) {
866
1183
  continue;
867
1184
  }
868
- const latest = latestReviews.get(key);
869
- if (latest === void 0 || result.reviewedAt > latest.reviewedAt) {
870
- latestReviews.set(key, { pr: result.pr, reviewedAt: result.reviewedAt });
1185
+ const known = latest.get(key);
1186
+ if (known === void 0 || result.reviewedAt > known.reviewedAt) {
1187
+ latest.set(key, { pr: result.pr, reviewedAt: result.reviewedAt });
1188
+ }
1189
+ }
1190
+ return [...latest.values()].toSorted((a, b) => a.reviewedAt.getTime() - b.reviewedAt.getTime());
1191
+ }
1192
+ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Date() } = {}) {
1193
+ const reviewed = [];
1194
+ for (const result of results) {
1195
+ if (result.kind === "reviewed") {
1196
+ reviewed.push({
1197
+ pr: result.pr,
1198
+ requestedAt: result.requestedAt,
1199
+ reviewedAt: result.reviewedAt,
1200
+ hours: durationHours(result.requestedAt, result.reviewedAt),
1201
+ verdict: result.verdict,
1202
+ lines: result.lines
1203
+ });
871
1204
  }
872
1205
  }
873
- const reviewing = [...latestReviews.values()].map(({ pr, reviewedAt }) => {
874
- return { pr, reviewedAt, hours: durationHours(reviewedAt, now) };
875
- }).toSorted((a, b) => a.reviewedAt.getTime() - b.reviewedAt.getTime());
1206
+ const pending = pendingRequests(results).map((entry) => {
1207
+ return { ...entry, hours: durationHours(entry.requestedAt, now) };
1208
+ });
1209
+ const reviewing = latestReviews(results).map((entry) => {
1210
+ return { ...entry, hours: durationHours(entry.reviewedAt, now) };
1211
+ });
876
1212
  const expired = results.filter((result) => result.kind === "pending" && result.pr.state !== "open");
877
1213
  const unrequested = results.filter((result) => result.kind === "unrequested");
878
1214
  const allHours = reviewed.map((result) => result.hours);
@@ -1125,12 +1461,24 @@ function queueRowAt(view, cursor) {
1125
1461
  return rows[Math.min(cursor, rows.length - 1)];
1126
1462
  }
1127
1463
  function snoozeActionOf(row) {
1128
- if (row?.pending === void 0) {
1464
+ if (row?.pending !== void 0) {
1465
+ return row.pending.snoozed ? "unsnooze" : "snooze";
1466
+ }
1467
+ if (row?.mention === void 0 || row.mention.state === "read") {
1468
+ return null;
1469
+ }
1470
+ return row.mention.state === "snoozed" ? "unsnooze" : "snooze";
1471
+ }
1472
+ function mentionActionOf(row) {
1473
+ if (row?.mention === void 0) {
1129
1474
  return null;
1130
1475
  }
1131
- return row.pending.snoozed ? "unsnooze" : "snooze";
1476
+ return row.mention.state === "read" ? "unread" : "read";
1132
1477
  }
1133
- function groupedLists(entries, rowsOf2) {
1478
+ function unreadMentionRows(view) {
1479
+ return view === null ? [] : queueRows(view).filter((row) => row.mention?.state === "unread");
1480
+ }
1481
+ function groupedLists(entries, rowsOf) {
1134
1482
  const groups = /* @__PURE__ */ new Map();
1135
1483
  for (const entry of entries) {
1136
1484
  const group = groups.get(entry.pr.repo) ?? [];
@@ -1138,40 +1486,108 @@ function groupedLists(entries, rowsOf2) {
1138
1486
  groups.set(entry.pr.repo, group);
1139
1487
  }
1140
1488
  return [...groups.entries()].toSorted((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])).map(([repo, group]) => {
1141
- return { title: `${repo} (n=${group.length})`, rows: rowsOf2(group) };
1489
+ return { title: `${repo} (n=${group.length})`, rows: rowsOf(group) };
1142
1490
  });
1143
1491
  }
1144
- function buildPendingReviewView(raw, repo = null, grouped = false, snoozes2 = [], now = Date.now()) {
1145
- const stats = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
1146
- const inScope = (entries) => repo === null ? entries : entries.filter((entry) => entry.pr.repo === repo);
1147
- const { awaiting, snoozed } = splitSnoozed(inScope(stats.pending), snoozes2, now);
1148
- const reviewing = inScope(stats.reviewing);
1149
- if (awaiting.length === 0 && snoozed.length === 0 && reviewing.length === 0) {
1150
- return { empty: "No PRs are awaiting your review, and none you reviewed are still open.", sections: [] };
1492
+ function inScopeOf(entries, repo) {
1493
+ return repo === null ? entries : entries.filter((entry) => entry.pr.repo === repo);
1494
+ }
1495
+ function sectionOf(title, entries, rowsOf, split) {
1496
+ return split ? { title, rows: [], lists: groupedLists(entries, rowsOf) } : { title, rows: rowsOf(entries), lists: [] };
1497
+ }
1498
+ function unreadMentionRefs(raw, snoozes2, reads, now) {
1499
+ const { unread } = splitMentions(mentionItems(raw.mentions ?? []), reads, snoozes2, now);
1500
+ return new Set(unread.map((item) => prKey(item.pr.repo, item.pr.number)));
1501
+ }
1502
+ function queueAlerts(raw, snoozes2 = [], reads = emptyMentionReads(), now = Date.now()) {
1503
+ const { awaiting } = splitSnoozed(pendingRequests(raw.reviewResults), snoozes2, now);
1504
+ const { unread } = splitMentions(mentionItems(raw.mentions ?? []), reads, snoozes2, now);
1505
+ return { pending: awaiting.length > 0, mentions: unread.length > 0 };
1506
+ }
1507
+ function buildPendingReviewView(raw, repo = null, grouped = false, snoozes2 = [], reads = emptyMentionReads(), now = Date.now()) {
1508
+ const { awaiting, snoozed } = splitSnoozed(inScopeOf(pendingRequests(raw.reviewResults), repo), snoozes2, now);
1509
+ if (awaiting.length === 0 && snoozed.length === 0) {
1510
+ return { empty: "No PRs are awaiting your review.", sections: [] };
1151
1511
  }
1152
1512
  const split = repo === null && grouped;
1153
- const sectionOf = (title, entries, rowsOf2) => split ? { title, rows: [], lists: groupedLists(entries, rowsOf2) } : { title, rows: rowsOf2(entries), lists: [] };
1154
- const snoozedRowsOf = (group) => snoozedRows(group, now);
1513
+ const mentioned = unreadMentionRefs(raw, snoozes2, reads, now);
1514
+ const awaitingRowsOf = (group) => waitRows(group, (entry) => entry.requestedAt, raw.fetchedAt).map((row, i) => {
1515
+ return {
1516
+ ...row,
1517
+ pending: { requestedAt: group[i].requestedAt.getTime(), snoozed: false },
1518
+ ...badgeOf(row, mentioned)
1519
+ };
1520
+ });
1521
+ const snoozedRowsOf = (group) => wakeRows(group, now).map((row, i) => {
1522
+ return {
1523
+ ...row,
1524
+ pending: { requestedAt: group[i].requestedAt.getTime(), snoozed: true },
1525
+ ...badgeOf(row, mentioned)
1526
+ };
1527
+ });
1155
1528
  return {
1156
1529
  empty: null,
1157
1530
  sections: [
1158
- ...awaiting.length === 0 ? [] : [sectionOf(`Awaiting your review (n=${awaiting.length})`, awaiting, awaitingRows)],
1159
- ...snoozed.length === 0 ? [] : [sectionOf(`Snoozed (n=${snoozed.length})`, snoozed, snoozedRowsOf)],
1160
- ...reviewing.length === 0 ? [] : [sectionOf(`Reviewed (n=${reviewing.length})`, reviewing, rowsOf)]
1531
+ ...awaiting.length === 0 ? [] : [sectionOf(`Awaiting your review (n=${awaiting.length})`, awaiting, awaitingRowsOf, split)],
1532
+ ...snoozed.length === 0 ? [] : [sectionOf(`Snoozed (n=${snoozed.length})`, snoozed, snoozedRowsOf, split)]
1161
1533
  ]
1162
1534
  };
1163
1535
  }
1164
- function awaitingRows(group) {
1165
- return rowsOf(group).map((row, i) => {
1166
- return { ...row, pending: { requestedAt: group[i].requestedAt.getTime(), snoozed: false } };
1536
+ function buildReviewedView(raw, repo = null, grouped = false, snoozes2 = [], reads = emptyMentionReads(), now = Date.now()) {
1537
+ const reviewing = inScopeOf(latestReviews(raw.reviewResults), repo);
1538
+ if (reviewing.length === 0) {
1539
+ return { empty: "No PRs you reviewed are still open.", sections: [] };
1540
+ }
1541
+ const mentioned = unreadMentionRefs(raw, snoozes2, reads, now);
1542
+ const reviewedRowsOf = (group) => waitRows(group, (entry) => entry.reviewedAt, raw.fetchedAt).map((row) => {
1543
+ return { ...row, ...badgeOf(row, mentioned) };
1167
1544
  });
1545
+ return {
1546
+ empty: null,
1547
+ sections: [sectionOf(`Reviewed (n=${reviewing.length})`, reviewing, reviewedRowsOf, repo === null && grouped)]
1548
+ };
1168
1549
  }
1169
- function snoozedRows(group, now) {
1550
+ function buildMentionsView(raw, repo = null, grouped = false, snoozes2 = [], reads = emptyMentionReads(), now = Date.now()) {
1551
+ if (raw.mentions === null) {
1552
+ return {
1553
+ empty: "Mention tracking is off. Turn on Track mentions in the settings (S) to list the PRs that mention you.",
1554
+ sections: []
1555
+ };
1556
+ }
1557
+ const { unread, snoozed, read } = splitMentions(inScopeOf(mentionItems(raw.mentions), repo), reads, snoozes2, now);
1558
+ if (unread.length === 0 && snoozed.length === 0 && read.length === 0) {
1559
+ return { empty: "No PR mentions you.", sections: [] };
1560
+ }
1561
+ const split = repo === null && grouped;
1562
+ const unreadRowsOf = (group) => mentionRows(group, "unread", raw.fetchedAt);
1563
+ const readRowsOf = (group) => mentionRows(group, "read", raw.fetchedAt);
1564
+ const snoozedRowsOf = (group) => wakeRows(group, now).map((row, i) => {
1565
+ return { ...row, mention: { mark: markOf(group[i]), state: "snoozed" } };
1566
+ });
1567
+ return {
1568
+ empty: null,
1569
+ sections: [
1570
+ ...unread.length === 0 ? [] : [sectionOf(`Unread (n=${unread.length})`, unread, unreadRowsOf, split)],
1571
+ ...snoozed.length === 0 ? [] : [sectionOf(`Snoozed (n=${snoozed.length})`, snoozed, snoozedRowsOf, split)],
1572
+ ...read.length === 0 ? [] : [sectionOf(`Read (n=${read.length})`, read, readRowsOf, split)]
1573
+ ]
1574
+ };
1575
+ }
1576
+ function badgeOf(row, mentioned) {
1577
+ return mentioned.has(row.ref) ? { mentioned: true } : {};
1578
+ }
1579
+ function wakeRows(group, now) {
1580
+ return toPrRows(
1581
+ group,
1582
+ group.map((item) => `until ${formatWakeTime(item.until, now)}`)
1583
+ );
1584
+ }
1585
+ function mentionRows(group, state, fetchedAt) {
1170
1586
  return toPrRows(
1171
1587
  group,
1172
- group.map((entry) => `until ${formatWakeTime(entry.until, now)}`)
1588
+ group.map((item) => durationLead({ hours: durationHours(new Date(item.mentionedAt), fetchedAt) }))
1173
1589
  ).map((row, i) => {
1174
- return { ...row, pending: { requestedAt: group[i].requestedAt.getTime(), snoozed: true } };
1590
+ return { ...row, mention: { mark: markOf(group[i]), state } };
1175
1591
  });
1176
1592
  }
1177
1593
  function buildOpenAuthoredView(raw, repo = null, grouped = false) {
@@ -1179,7 +1595,7 @@ function buildOpenAuthoredView(raw, repo = null, grouped = false) {
1179
1595
  if (open.length === 0) {
1180
1596
  return { empty: "No open authored PRs found.", sections: [] };
1181
1597
  }
1182
- const rowsOf2 = (group) => {
1598
+ const rowsOf = (group) => {
1183
1599
  const ages = group.map((entry) => durationLead({ hours: durationHours(entry.pr.createdAt, raw.fetchedAt) }));
1184
1600
  const ageWidth = Math.max(...ages.map((age) => age.length));
1185
1601
  return toPrRows(
@@ -1191,14 +1607,14 @@ function buildOpenAuthoredView(raw, repo = null, grouped = false) {
1191
1607
  };
1192
1608
  const title = `Your open authored PRs (n=${open.length})`;
1193
1609
  if (repo === null && grouped) {
1194
- return { empty: null, sections: [{ title, rows: [], lists: groupedLists(open, rowsOf2) }] };
1610
+ return { empty: null, sections: [{ title, rows: [], lists: groupedLists(open, rowsOf) }] };
1195
1611
  }
1196
- return { empty: null, sections: [{ title, rows: rowsOf2(open), lists: [] }] };
1612
+ return { empty: null, sections: [{ title, rows: rowsOf(open), lists: [] }] };
1197
1613
  }
1198
- function rowsOf(group) {
1614
+ function waitRows(group, startOf, fetchedAt) {
1199
1615
  return toPrRows(
1200
1616
  group,
1201
- group.map((entry) => durationLead(entry))
1617
+ group.map((entry) => durationLead({ hours: durationHours(startOf(entry), fetchedAt) }))
1202
1618
  );
1203
1619
  }
1204
1620
 
@@ -1208,10 +1624,8 @@ function Footer({
1208
1624
  width,
1209
1625
  modal,
1210
1626
  editing,
1211
- tab,
1212
- authoredTab,
1627
+ browse,
1213
1628
  views,
1214
- pendingCursor,
1215
1629
  copyLinks: copyLinks2,
1216
1630
  openError,
1217
1631
  successNotice,
@@ -1220,10 +1634,7 @@ function Footer({
1220
1634
  const notice = openError ?? successNotice ?? (stale ? "options changed \xB7 press r to reload" : "");
1221
1635
  const check = openError === null && successNotice !== null;
1222
1636
  const noticeWidth = notice === "" ? 0 : notice.length + (check ? 2 : 0) + 2;
1223
- const hints = truncated(
1224
- hintsFor(modal, editing, tab, authoredTab, views, pendingCursor, copyLinks2),
1225
- width - 2 - noticeWidth
1226
- );
1637
+ const hints = truncated(hintsFor(modal, editing, browse, views, copyLinks2), width - 2 - noticeWidth);
1227
1638
  return /* @__PURE__ */ jsxs(Fragment, { children: [
1228
1639
  /* @__PURE__ */ jsx("box", { height: 1, children: /* @__PURE__ */ jsx("text", { wrapMode: "none", fg: theme.border, children: "\u2500".repeat(width) }) }),
1229
1640
  /* @__PURE__ */ jsxs(
@@ -1252,7 +1663,37 @@ function truncated(text, limit2) {
1252
1663
  }
1253
1664
  return limit2 <= 1 ? "" : `${text.slice(0, limit2 - 1).trimEnd()}\u2026`;
1254
1665
  }
1255
- function hintsFor(modal, editing, tab, authoredTab, views, pendingCursor, copyLinks2) {
1666
+ function queueViewsOf(key, views) {
1667
+ if (key === "pending") {
1668
+ return { view: views.pending, repos: views.pendingRepos, scope: views.pendingScope };
1669
+ }
1670
+ if (key === "reviewed") {
1671
+ return { view: views.reviewed, repos: views.reviewedRepos, scope: views.reviewedScope };
1672
+ }
1673
+ if (key === "mentions") {
1674
+ return { view: views.mentions, repos: views.mentionsRepos, scope: views.mentionsScope };
1675
+ }
1676
+ return { view: views.open, repos: views.openRepos, scope: views.openScope };
1677
+ }
1678
+ function subTabHint(browse) {
1679
+ if (browse.tab === 0) {
1680
+ return `t ${cycledPendingSubTab(browse.pendingTab, 1).label.toLowerCase()} \xB7 `;
1681
+ }
1682
+ if (browse.tab === 1) {
1683
+ return browse.authoredTab === "open" ? "t merged stats \xB7 " : "t open PRs \xB7 ";
1684
+ }
1685
+ return "";
1686
+ }
1687
+ function queueKeyHints(view, cursor) {
1688
+ const row = queueRowAt(view, cursor);
1689
+ const snoozeAction = snoozeActionOf(row);
1690
+ const mentionAction = mentionActionOf(row);
1691
+ const snooze = snoozeAction === null ? "" : `s ${snoozeAction} \xB7 `;
1692
+ const mark = mentionAction === null ? "" : `d mark ${mentionAction} \xB7 `;
1693
+ const markAll = unreadMentionRows(view).length > 0 ? "D read all \xB7 " : "";
1694
+ return `${snooze}${mark}${markAll}`;
1695
+ }
1696
+ function hintsFor(modal, editing, browse, views, copyLinks2) {
1256
1697
  if (modal === "options") {
1257
1698
  return editing ? "enter apply \xB7 esc cancel" : "\u2191/\u2193 select \xB7 enter edit \xB7 \u2190/\u2192 toggle \xB7 s save \xB7 esc close \xB7 q quit";
1258
1699
  }
@@ -1265,20 +1706,20 @@ function hintsFor(modal, editing, tab, authoredTab, views, pendingCursor, copyLi
1265
1706
  if (modal === "snooze") {
1266
1707
  return "enter snooze \xB7 esc cancel";
1267
1708
  }
1268
- const toggle = tab === 1 ? authoredTab === "open" ? "t merged stats \xB7 " : "t open PRs \xB7 " : "";
1269
- if (tab === 0 || tab === 1 && authoredTab === "open") {
1270
- const scope2 = views === null ? null : tab === 0 ? views.pendingScope : views.openScope;
1271
- const repos2 = views === null ? [] : tab === 0 ? views.pendingRepos : views.openRepos;
1709
+ const { tab } = browse;
1710
+ const toggle = subTabHint(browse);
1711
+ const queue = activeQueueTab(browse);
1712
+ if (queue !== null) {
1713
+ const { view: view2, repos: repos2, scope: scope2 } = views === null ? { view: null, repos: [], scope: null } : queueViewsOf(queue, views);
1272
1714
  if (scope2?.view === "list") {
1273
1715
  return `\u2191/\u2193 select \xB7 enter open \xB7 ${toggle}\u2190/\u2192 tabs \xB7 o options \xB7 S settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
1274
1716
  }
1275
- const snoozeAction = tab === 0 && views !== null ? snoozeActionOf(queueRowAt(views.pending, pendingCursor)) : null;
1276
- const snooze = snoozeAction === null ? "" : `s ${snoozeAction} \xB7 `;
1717
+ const queueKeys = queueKeyHints(view2, browse.rowCursors[queue]);
1277
1718
  const action = copyLinks2 ? "enter copy link" : "enter open";
1278
1719
  if (scope2 !== null && repos2.length > 0) {
1279
- return scope2.repo === null ? `\u2191/\u2193 select \xB7 ${action} \xB7 ${snooze}${toggle}g group by repo \xB7 esc back \xB7 o options \xB7 S settings \xB7 r reload \xB7 q quit` : `\u2191/\u2193 select \xB7 ${action} \xB7 ${snooze}${toggle}esc back \xB7 1-5 tabs \xB7 o options \xB7 S settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
1720
+ return scope2.repo === null ? `\u2191/\u2193 select \xB7 ${action} \xB7 ${queueKeys}${toggle}g group by repo \xB7 esc back \xB7 o options \xB7 S settings \xB7 r reload \xB7 q quit` : `\u2191/\u2193 select \xB7 ${action} \xB7 ${queueKeys}${toggle}esc back \xB7 1-5 tabs \xB7 o options \xB7 S settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
1280
1721
  }
1281
- return `\u2191/\u2193 select \xB7 ${copyLinks2 ? "enter copy link" : "enter open in browser"} \xB7 ${snooze}${toggle}\u2190/\u2192 tabs \xB7 o options \xB7 S settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
1722
+ return `\u2191/\u2193 select \xB7 ${copyLinks2 ? "enter copy link" : "enter open in browser"} \xB7 ${queueKeys}${toggle}\u2190/\u2192 tabs \xB7 o options \xB7 S settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
1282
1723
  }
1283
1724
  const scope = views === null ? null : tab === 1 ? views.mergedScope : tab === 2 ? views.reviewScope : tab === 3 ? views.sizeScope : views.commentScope;
1284
1725
  const repos = views === null ? [] : tab === 1 ? views.mergedRepos : tab === 2 ? views.reviewRepos : tab === 3 ? views.sizeRepos : views.commentRepos;
@@ -2536,7 +2977,8 @@ function QueuePanel({
2536
2977
  const renderRow = (row, index, indent) => {
2537
2978
  const isSelected = index === cursor;
2538
2979
  const bg = isSelected ? theme.selectedBg : void 0;
2539
- const fg = row.pending?.snoozed === true ? theme.muted : theme.text;
2980
+ const parked = row.pending?.snoozed === true || row.mention?.state === "snoozed" || row.mention?.state === "read";
2981
+ const fg = parked ? theme.muted : theme.text;
2540
2982
  const refStart = indent.length + 2 + row.lead.length + 2;
2541
2983
  return /* @__PURE__ */ jsxs5(
2542
2984
  "text",
@@ -2567,10 +3009,9 @@ function QueuePanel({
2567
3009
  " "
2568
3010
  ] }),
2569
3011
  onRefClick === null ? /* @__PURE__ */ jsx6("a", { href: row.url, fg: theme.accent, bg, children: row.ref }) : /* @__PURE__ */ jsx6("span", { fg: theme.accent, bg, children: row.ref }),
2570
- /* @__PURE__ */ jsxs5("span", { fg, bg, children: [
2571
- " ",
2572
- row.title
2573
- ] })
3012
+ /* @__PURE__ */ jsx6("span", { fg, bg, children: " " }),
3013
+ row.mentioned === true && /* @__PURE__ */ jsx6("span", { fg: theme.accent, bg, children: "@ " }),
3014
+ /* @__PURE__ */ jsx6("span", { fg, bg, children: row.title })
2574
3015
  ]
2575
3016
  },
2576
3017
  row.url
@@ -2647,91 +3088,6 @@ function RepoList({
2647
3088
  );
2648
3089
  }
2649
3090
 
2650
- // src/tui/state/browse.ts
2651
- var TABS = ["1 Awaiting you", "2 Your PRs", "3 Reviews", "4 PR size", "5 Comments"];
2652
- var initialBrowseState = {
2653
- tab: 0,
2654
- authoredTab: "open",
2655
- scopes: {
2656
- pending: { view: "list" },
2657
- open: { view: "list" },
2658
- review: { view: "list" },
2659
- size: { view: "list" },
2660
- comment: { view: "list" },
2661
- merged: { view: "list" }
2662
- },
2663
- repoCursors: { pending: 0, open: 0, review: 0, size: 0, comment: 0, merged: 0 },
2664
- rowCursors: { pending: 0, open: 0 },
2665
- grouped: { pending: false, open: false },
2666
- expanded: { review: false, size: false, comment: false, merged: false }
2667
- };
2668
- function dropVanishedRepo(scope, repos) {
2669
- if (scope.view === "detail" && scope.repo !== null && !repos.some((option) => option.repo === scope.repo)) {
2670
- return { view: "list" };
2671
- }
2672
- return scope;
2673
- }
2674
- function cycled(previous, delta, count2) {
2675
- return (Math.min(previous, count2 - 1) + count2 + delta) % count2;
2676
- }
2677
- function browseReducer(state, action) {
2678
- switch (action.type) {
2679
- case "tabSelected": {
2680
- return { ...state, tab: action.tab };
2681
- }
2682
- case "tabCycled": {
2683
- return { ...state, tab: (state.tab + TABS.length + action.delta) % TABS.length };
2684
- }
2685
- case "subTabToggled": {
2686
- return { ...state, authoredTab: state.authoredTab === "open" ? "merged" : "open" };
2687
- }
2688
- case "repoCursorMoved": {
2689
- return {
2690
- ...state,
2691
- repoCursors: {
2692
- ...state.repoCursors,
2693
- [action.tab]: cycled(state.repoCursors[action.tab], action.delta, action.count)
2694
- }
2695
- };
2696
- }
2697
- case "rowCursorMoved": {
2698
- return {
2699
- ...state,
2700
- rowCursors: {
2701
- ...state.rowCursors,
2702
- [action.tab]: cycled(state.rowCursors[action.tab], action.delta, action.count)
2703
- }
2704
- };
2705
- }
2706
- case "repoOpened": {
2707
- return { ...state, scopes: { ...state.scopes, [action.tab]: { view: "detail", repo: action.repo } } };
2708
- }
2709
- case "pickerReturned": {
2710
- return { ...state, scopes: { ...state.scopes, [action.tab]: { view: "list" } } };
2711
- }
2712
- case "groupingToggled": {
2713
- return { ...state, grouped: { ...state.grouped, [action.tab]: !state.grouped[action.tab] } };
2714
- }
2715
- case "expandToggled": {
2716
- return { ...state, expanded: { ...state.expanded, [action.tab]: !state.expanded[action.tab] } };
2717
- }
2718
- case "dataLoaded": {
2719
- return {
2720
- ...state,
2721
- scopes: {
2722
- pending: dropVanishedRepo(state.scopes.pending, action.repos.pending),
2723
- open: dropVanishedRepo(state.scopes.open, action.repos.open),
2724
- review: dropVanishedRepo(state.scopes.review, action.repos.review),
2725
- size: dropVanishedRepo(state.scopes.size, action.repos.size),
2726
- comment: dropVanishedRepo(state.scopes.comment, action.repos.comment),
2727
- merged: dropVanishedRepo(state.scopes.merged, action.repos.merged)
2728
- }
2729
- };
2730
- }
2731
- }
2732
- return state;
2733
- }
2734
-
2735
3091
  // src/tui/components/TabBar.tsx
2736
3092
  import { jsx as jsx8, jsxs as jsxs7 } from "@opentui/react/jsx-runtime";
2737
3093
  function TabBar({ tab }) {
@@ -2746,23 +3102,22 @@ function TabBar({ tab }) {
2746
3102
  label
2747
3103
  )) });
2748
3104
  }
2749
- var SUB_TABS = [
2750
- { key: "open", label: "Open" },
2751
- { key: "merged", label: "Merged & closed" }
2752
- ];
2753
- function SubTabBar({ active }) {
3105
+ function SubTabBar({
3106
+ tabs,
3107
+ active,
3108
+ alerts = {}
3109
+ }) {
2754
3110
  return /* @__PURE__ */ jsxs7("box", { flexDirection: "row", height: 1, paddingLeft: 1, marginBottom: 1, columnGap: 1, children: [
2755
- SUB_TABS.map(({ key, label }) => /* @__PURE__ */ jsx8(
2756
- "text",
2757
- {
2758
- wrapMode: "none",
2759
- fg: key === active ? theme.accent : theme.muted,
2760
- bg: key === active ? theme.selectedBg : void 0,
2761
- children: ` ${label} `
2762
- },
2763
- key
2764
- )),
2765
- /* @__PURE__ */ jsx8("text", { wrapMode: "none", fg: theme.dim, children: "t switches" })
3111
+ tabs.map(({ key, label }) => {
3112
+ const isActive = key === active;
3113
+ const bg = isActive ? theme.selectedBg : void 0;
3114
+ return /* @__PURE__ */ jsxs7("text", { wrapMode: "none", children: [
3115
+ /* @__PURE__ */ jsx8("span", { fg: isActive ? theme.accent : theme.muted, bg, children: " " }),
3116
+ alerts[key] === true && /* @__PURE__ */ jsx8("span", { fg: theme.accent, bg, children: "* " }),
3117
+ /* @__PURE__ */ jsx8("span", { fg: isActive ? theme.accent : theme.muted, bg, children: `${label} ` })
3118
+ ] }, key);
3119
+ }),
3120
+ /* @__PURE__ */ jsx8("text", { wrapMode: "none", fg: theme.dim, children: "t/T switches" })
2766
3121
  ] });
2767
3122
  }
2768
3123
 
@@ -2834,21 +3189,50 @@ function MainPanel({
2834
3189
  load,
2835
3190
  onRefClick
2836
3191
  }) {
2837
- return /* @__PURE__ */ jsx9("box", { flexGrow: 1, flexDirection: "column", marginTop: 1, children: views === null ? /* @__PURE__ */ jsx9(Placeholder, { error, loading, load }) : browse.tab === 0 ? /* @__PURE__ */ jsx9(
2838
- QueueTab,
2839
- {
2840
- prompt: "Select a repository and press enter to open its review queue.",
2841
- repos: views.pendingRepos,
2842
- scope: views.pendingScope,
2843
- view: views.pending,
2844
- repoCursor: browse.repoCursors.pending,
2845
- rowCursor: browse.rowCursors.pending,
2846
- grouped: browse.grouped.pending,
2847
- warning,
2848
- onRefClick
2849
- }
2850
- ) : browse.tab === 1 ? /* @__PURE__ */ jsxs8("box", { flexGrow: 1, flexDirection: "column", children: [
2851
- /* @__PURE__ */ jsx9(SubTabBar, { active: browse.authoredTab }),
3192
+ return /* @__PURE__ */ jsx9("box", { flexGrow: 1, flexDirection: "column", marginTop: 1, children: views === null ? /* @__PURE__ */ jsx9(Placeholder, { error, loading, load }) : browse.tab === 0 ? /* @__PURE__ */ jsxs8("box", { flexGrow: 1, flexDirection: "column", children: [
3193
+ /* @__PURE__ */ jsx9(SubTabBar, { tabs: PENDING_SUB_TABS, active: browse.pendingTab, alerts: views.alerts }),
3194
+ browse.pendingTab === "pending" ? /* @__PURE__ */ jsx9(
3195
+ QueueTab,
3196
+ {
3197
+ prompt: "Select a repository and press enter to open its review queue.",
3198
+ repos: views.pendingRepos,
3199
+ scope: views.pendingScope,
3200
+ view: views.pending,
3201
+ repoCursor: browse.repoCursors.pending,
3202
+ rowCursor: browse.rowCursors.pending,
3203
+ grouped: browse.grouped.pending,
3204
+ warning,
3205
+ onRefClick
3206
+ }
3207
+ ) : browse.pendingTab === "reviewed" ? /* @__PURE__ */ jsx9(
3208
+ QueueTab,
3209
+ {
3210
+ prompt: "Select a repository and press enter to list the open PRs you reviewed.",
3211
+ repos: views.reviewedRepos,
3212
+ scope: views.reviewedScope,
3213
+ view: views.reviewed,
3214
+ repoCursor: browse.repoCursors.reviewed,
3215
+ rowCursor: browse.rowCursors.reviewed,
3216
+ grouped: browse.grouped.reviewed,
3217
+ warning,
3218
+ onRefClick
3219
+ }
3220
+ ) : /* @__PURE__ */ jsx9(
3221
+ QueueTab,
3222
+ {
3223
+ prompt: "Select a repository and press enter to open its mention inbox.",
3224
+ repos: views.mentionsRepos,
3225
+ scope: views.mentionsScope,
3226
+ view: views.mentions,
3227
+ repoCursor: browse.repoCursors.mentions,
3228
+ rowCursor: browse.rowCursors.mentions,
3229
+ grouped: browse.grouped.mentions,
3230
+ warning,
3231
+ onRefClick
3232
+ }
3233
+ )
3234
+ ] }) : browse.tab === 1 ? /* @__PURE__ */ jsxs8("box", { flexGrow: 1, flexDirection: "column", children: [
3235
+ /* @__PURE__ */ jsx9(SubTabBar, { tabs: AUTHORED_SUB_TABS, active: browse.authoredTab }),
2852
3236
  browse.authoredTab === "open" ? /* @__PURE__ */ jsx9(
2853
3237
  QueueTab,
2854
3238
  {
@@ -3405,23 +3789,24 @@ import { useRenderer as useRenderer2 } from "@opentui/react";
3405
3789
  import { homedir as homedir2 } from "node:os";
3406
3790
 
3407
3791
  // src/tui/data/export.ts
3408
- import { join as join5 } from "node:path";
3792
+ import { join as join6 } from "node:path";
3409
3793
 
3410
3794
  // src/github.ts
3411
3795
  import { execFile } from "node:child_process";
3412
3796
  import { createHash } from "node:crypto";
3413
3797
  import { statSync } from "node:fs";
3414
- import { join as join4, resolve } from "node:path";
3798
+ import { join as join5, resolve } from "node:path";
3415
3799
  import { promisify } from "node:util";
3416
3800
  var execFileAsync = promisify(execFile);
3417
3801
  var API_BASE = "https://api.github.com";
3802
+ var SEARCH_LIMIT = 1e3;
3418
3803
  var token;
3419
3804
  var ghBinary = "gh";
3420
3805
  function resolveDebugBinary(input) {
3421
3806
  const resolved = resolve(input);
3422
3807
  const stats = statSync(resolved, { throwIfNoEntry: false });
3423
3808
  if (stats?.isDirectory()) {
3424
- const binary = join4(resolved, "gh");
3809
+ const binary = join5(resolved, "gh");
3425
3810
  if (!statSync(binary, { throwIfNoEntry: false })?.isFile()) {
3426
3811
  throw new CliError(`--debug directory "${input}" does not contain a gh executable`);
3427
3812
  }
@@ -3546,27 +3931,41 @@ async function resolveRepos(repos) {
3546
3931
  }
3547
3932
  return resolved;
3548
3933
  }
3549
- async function searchPrsViaApi({ user, sinceIso, repos, includeDrafts, mode }) {
3550
- const qualifier = {
3551
- requested: "review-requested",
3552
- reviewed: "reviewed-by",
3553
- authored: "author"
3554
- }[mode];
3555
- const terms = ["type:pr", `${qualifier}:${user}`, `created:>=${sinceIso}`];
3556
- if (mode !== "authored") {
3934
+ function excludesOwnPrs(mode) {
3935
+ return mode === "requested" || mode === "reviewed";
3936
+ }
3937
+ function isMentionQuery(query) {
3938
+ return query === "mentioned" || query === "mentionedText";
3939
+ }
3940
+ function mentionTerm(user) {
3941
+ return `"@${user}"`;
3942
+ }
3943
+ async function searchPrsViaApi({ user, sinceIso, repos, includeDrafts, query }) {
3944
+ const terms = ["type:pr"];
3945
+ if (query === "mentioned") {
3946
+ terms.push(`mentions:${user}`, `updated:>=${sinceIso}`);
3947
+ } else if (query === "mentionedText") {
3948
+ terms.push(mentionTerm(user), `involves:${user}`, `updated:>=${sinceIso}`);
3949
+ } else {
3950
+ const qualifier = { requested: "review-requested", reviewed: "reviewed-by", authored: "author" }[query];
3951
+ terms.push(`${qualifier}:${user}`, `created:>=${sinceIso}`);
3952
+ }
3953
+ const mode = isMentionQuery(query) ? "mentioned" : query;
3954
+ if (excludesOwnPrs(mode)) {
3557
3955
  terms.push(`-author:${user}`);
3558
3956
  }
3559
- if (!includeDrafts) {
3957
+ if (!includeDrafts && mode !== "mentioned") {
3560
3958
  terms.push("draft:false");
3561
3959
  }
3562
3960
  for (const repo of repos) {
3563
3961
  terms.push(`repo:${repo}`);
3564
3962
  }
3565
- const query = encodeURIComponent(terms.join(" "));
3963
+ const encoded = encodeURIComponent(terms.join(" "));
3964
+ const sort = mode === "mentioned" ? "&sort=updated&order=desc" : "";
3566
3965
  const items = [];
3567
- for (let page = 1; page <= 10; page++) {
3966
+ for (let page = 1; page <= SEARCH_LIMIT / 100; page++) {
3568
3967
  const result = await api(
3569
- `/search/issues?q=${query}&per_page=100&page=${page}`
3968
+ `/search/issues?q=${encoded}${sort}&per_page=100&page=${page}`
3570
3969
  );
3571
3970
  items.push(...result.items);
3572
3971
  if (result.items.length === 0 || items.length >= result.total_count) {
@@ -3580,39 +3979,60 @@ async function searchPrsViaApi({ user, sinceIso, repos, includeDrafts, mode }) {
3580
3979
  title: item.title,
3581
3980
  url: item.html_url,
3582
3981
  createdAt: item.created_at,
3982
+ updatedAt: item.updated_at,
3583
3983
  isDraft: item.draft,
3584
3984
  state: item.state
3585
3985
  };
3586
3986
  });
3587
3987
  }
3588
- async function searchPrs({ user, sinceIso, repos, includeDrafts, mode }) {
3988
+ async function searchPrs({ user, sinceIso, repos, includeDrafts, mode }) {
3989
+ const queries = mode === "mentioned" ? ["mentioned", "mentionedText"] : [mode];
3990
+ const results = await Promise.all(
3991
+ queries.map((query) => {
3992
+ return searchOnce({ user, sinceIso, repos, includeDrafts, query });
3993
+ })
3994
+ );
3995
+ const capped = results.some((items) => items.length >= SEARCH_LIMIT);
3996
+ if (results.length === 1) {
3997
+ return { items: results[0], capped };
3998
+ }
3999
+ const byRef = /* @__PURE__ */ new Map();
4000
+ for (const item of results.flat()) {
4001
+ const ref = `${item.repository.nameWithOwner}#${item.number}`;
4002
+ const known = byRef.get(ref);
4003
+ if (known === void 0 || item.updatedAt > known.updatedAt) {
4004
+ byRef.set(ref, item);
4005
+ }
4006
+ }
4007
+ return { items: [...byRef.values()], capped };
4008
+ }
4009
+ async function searchOnce({ user, sinceIso, repos, includeDrafts, query }) {
3589
4010
  if (token) {
3590
- return searchPrsViaApi({ user, sinceIso, repos, includeDrafts, mode });
4011
+ return searchPrsViaApi({ user, sinceIso, repos, includeDrafts, query });
3591
4012
  }
3592
- const modeFlag = {
3593
- requested: "--review-requested",
3594
- reviewed: "--reviewed-by",
3595
- authored: "--author"
3596
- }[mode];
4013
+ const mode = isMentionQuery(query) ? "mentioned" : query;
4014
+ const selection = query === "mentioned" ? ["--mentions", user, "--updated", `>=${sinceIso}`, "--sort", "updated", "--order", "desc"] : query === "mentionedText" ? [mentionTerm(user), "--involves", user, "--updated", `>=${sinceIso}`, "--sort", "updated", "--order", "desc"] : [
4015
+ { requested: "--review-requested", reviewed: "--reviewed-by", authored: "--author" }[query],
4016
+ user,
4017
+ "--created",
4018
+ `>=${sinceIso}`
4019
+ ];
3597
4020
  const args = [
3598
4021
  "search",
3599
4022
  "prs",
3600
- modeFlag,
3601
- user,
3602
- "--created",
3603
- `>=${sinceIso}`,
4023
+ ...selection,
3604
4024
  "--limit",
3605
- "1000",
4025
+ String(SEARCH_LIMIT),
3606
4026
  "--json",
3607
- "number,repository,title,url,createdAt,isDraft,state"
4027
+ "number,repository,title,url,createdAt,updatedAt,isDraft,state"
3608
4028
  ];
3609
- if (!includeDrafts) {
4029
+ if (!includeDrafts && mode !== "mentioned") {
3610
4030
  args.push("--draft=false");
3611
4031
  }
3612
4032
  for (const repo of repos) {
3613
4033
  args.push("--repo", repo);
3614
4034
  }
3615
- if (mode !== "authored") {
4035
+ if (excludesOwnPrs(mode)) {
3616
4036
  args.push("--", `-author:${user}`);
3617
4037
  }
3618
4038
  return JSON.parse(await gh(args));
@@ -3680,6 +4100,125 @@ async function fetchPrSizes(prs) {
3680
4100
  const data = await runGraphql(query);
3681
4101
  return prs.map((pr, i) => data[`pr${i}`]?.pullRequest ?? null);
3682
4102
  }
4103
+ var MENTION_TEXT_FRAGMENT = `
4104
+ fragment MentionText on Comment {
4105
+ id
4106
+ body
4107
+ createdAt
4108
+ publishedAt
4109
+ lastEditedAt
4110
+ author { login }
4111
+ }`;
4112
+ var PAGE_INFO = "pageInfo { hasNextPage endCursor }";
4113
+ var COMMENTS_PAGE = 100;
4114
+ var REVIEWS_PAGE = 50;
4115
+ var REVIEW_COMMENTS_PAGE = 20;
4116
+ function textPage(field, size, after) {
4117
+ const cursor = after === void 0 ? "" : `, after: ${JSON.stringify(after)}`;
4118
+ return `${field}(first: ${size}${cursor}) { ${PAGE_INFO} nodes { ...MentionText } }`;
4119
+ }
4120
+ function reviewsPage(after) {
4121
+ const cursor = after === void 0 ? "" : `, after: ${JSON.stringify(after)}`;
4122
+ return `reviews(first: ${REVIEWS_PAGE}${cursor}) {
4123
+ ${PAGE_INFO}
4124
+ nodes {
4125
+ id
4126
+ body
4127
+ submittedAt
4128
+ lastEditedAt
4129
+ author { login }
4130
+ ${textPage("comments", REVIEW_COMMENTS_PAGE)}
4131
+ }
4132
+ }`;
4133
+ }
4134
+ function pullRequestSelection(pr, selection) {
4135
+ const [owner, name] = pr.repo.split("/");
4136
+ return `repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {
4137
+ pullRequest(number: ${pr.number}) { ${selection} }
4138
+ }`;
4139
+ }
4140
+ async function completeMentionPages(pr, raw) {
4141
+ for (; ; ) {
4142
+ const parts = [];
4143
+ if (raw.comments.pageInfo.hasNextPage) {
4144
+ const after = raw.comments.pageInfo.endCursor ?? void 0;
4145
+ parts.push(`comments: ${pullRequestSelection(pr, textPage("comments", COMMENTS_PAGE, after))}`);
4146
+ }
4147
+ if (raw.reviews.pageInfo.hasNextPage) {
4148
+ parts.push(`reviews: ${pullRequestSelection(pr, reviewsPage(raw.reviews.pageInfo.endCursor ?? void 0))}`);
4149
+ }
4150
+ const incompleteReviews = [];
4151
+ for (const [i, review] of raw.reviews.nodes.entries()) {
4152
+ if (review?.comments.pageInfo.hasNextPage) {
4153
+ const after = review.comments.pageInfo.endCursor ?? void 0;
4154
+ const page = textPage("comments", COMMENTS_PAGE, after);
4155
+ incompleteReviews.push(i);
4156
+ parts.push(`review${i}: node(id: ${JSON.stringify(review.id)}) { ... on PullRequestReview { ${page} } }`);
4157
+ }
4158
+ }
4159
+ if (parts.length === 0) {
4160
+ return true;
4161
+ }
4162
+ const query = `query {${parts.join("\n")}}
4163
+ ${MENTION_TEXT_FRAGMENT}`;
4164
+ const data = await runGraphql(query);
4165
+ const pages = [];
4166
+ if (raw.comments.pageInfo.hasNextPage) {
4167
+ pages.push([raw.comments, data.comments?.pullRequest?.comments]);
4168
+ }
4169
+ if (raw.reviews.pageInfo.hasNextPage) {
4170
+ pages.push([raw.reviews, data.reviews?.pullRequest?.reviews]);
4171
+ }
4172
+ for (const i of incompleteReviews) {
4173
+ const review = raw.reviews.nodes[i];
4174
+ if (review != null) {
4175
+ pages.push([review.comments, data[`review${i}`]?.comments]);
4176
+ }
4177
+ }
4178
+ if (pages.some(([, next]) => next === void 0)) {
4179
+ return false;
4180
+ }
4181
+ for (const [list, next] of pages) {
4182
+ appendPage(list, next);
4183
+ }
4184
+ }
4185
+ }
4186
+ function appendPage(list, next) {
4187
+ if (next !== void 0) {
4188
+ list.nodes.push(...next.nodes);
4189
+ list.pageInfo = next.pageInfo;
4190
+ }
4191
+ }
4192
+ function present(nodes) {
4193
+ return nodes.filter((node) => node !== null);
4194
+ }
4195
+ async function fetchPrMentionDetails(prs) {
4196
+ const parts = prs.map((pr, i) => {
4197
+ const selection = `...MentionText ${textPage("comments", COMMENTS_PAGE)} ${reviewsPage()}`;
4198
+ return `pr${i}: ${pullRequestSelection(pr, selection)}`;
4199
+ });
4200
+ const query = `query {${parts.join("\n")}}
4201
+ ${MENTION_TEXT_FRAGMENT}`;
4202
+ const data = await runGraphql(query);
4203
+ const raws = prs.map((pr, i) => data[`pr${i}`]?.pullRequest ?? null);
4204
+ for (const [i, raw] of raws.entries()) {
4205
+ if (raw !== null && !await completeMentionPages(prs[i], raw)) {
4206
+ raws[i] = null;
4207
+ }
4208
+ }
4209
+ return raws.map((raw) => {
4210
+ if (raw === null) {
4211
+ return null;
4212
+ }
4213
+ return {
4214
+ ...raw,
4215
+ comments: present(raw.comments.nodes),
4216
+ reviews: present(raw.reviews.nodes).map((review) => {
4217
+ return { ...review, comments: present(review.comments.nodes) };
4218
+ })
4219
+ };
4220
+ });
4221
+ }
3683
4222
 
3684
4223
  // src/data.ts
3685
4224
  var BATCH_SIZE = 25;
@@ -3706,7 +4245,7 @@ function createLimiter(maxConcurrent) {
3706
4245
  };
3707
4246
  }
3708
4247
  var limit = createLimiter(MAX_CONCURRENT_BATCHES);
3709
- async function fetchMissing(misses, fetchBatch, found, cache, onBatchDone) {
4248
+ async function fetchMissing(misses, fetchBatch, found, cache, onBatchDone, cacheable = (pr) => pr.state !== "open") {
3710
4249
  const batches = [];
3711
4250
  for (let offset = 0; offset < misses.length; offset += BATCH_SIZE) {
3712
4251
  batches.push(misses.slice(offset, offset + BATCH_SIZE));
@@ -3720,7 +4259,7 @@ async function fetchMissing(misses, fetchBatch, found, cache, onBatchDone) {
3720
4259
  const details = detailsList[i];
3721
4260
  const key = prKey(pr.repo, pr.number);
3722
4261
  found.set(key, details);
3723
- if (details !== null && pr.state !== "open") {
4262
+ if (details !== null && cacheable(pr)) {
3724
4263
  cache.set(key, details);
3725
4264
  }
3726
4265
  }
@@ -3888,12 +4427,153 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
3888
4427
  }
3889
4428
  return { sizes, cacheHits };
3890
4429
  }
4430
+ function collectMentionedPrs(mentioned) {
4431
+ return mentioned.map((item) => {
4432
+ return {
4433
+ repo: item.repository.nameWithOwner,
4434
+ number: item.number,
4435
+ title: item.title,
4436
+ url: item.url,
4437
+ state: item.state,
4438
+ createdAt: new Date(item.createdAt),
4439
+ updatedAt: new Date(item.updatedAt)
4440
+ };
4441
+ });
4442
+ }
4443
+ function mentionPattern(user) {
4444
+ const escaped = user.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
4445
+ return new RegExp(String.raw`(?<![\w/-])@${escaped}(?![\w-])`, "i");
4446
+ }
4447
+ function latestOf(...times) {
4448
+ let latest = Number.NEGATIVE_INFINITY;
4449
+ for (const time of times) {
4450
+ if (time !== null) {
4451
+ latest = Math.max(latest, new Date(time).getTime());
4452
+ }
4453
+ }
4454
+ return new Date(latest);
4455
+ }
4456
+ function findMentions(details, user) {
4457
+ const pattern = mentionPattern(user);
4458
+ const own = user.toLowerCase();
4459
+ const sources = [];
4460
+ const addText = (text, floor = null) => {
4461
+ sources.push({
4462
+ id: text.id,
4463
+ body: text.body,
4464
+ at: latestOf(text.publishedAt ?? text.createdAt, text.lastEditedAt, floor),
4465
+ login: text.author?.login ?? null
4466
+ });
4467
+ };
4468
+ addText(details);
4469
+ for (const comment of details.comments) {
4470
+ addText(comment);
4471
+ }
4472
+ for (const review of details.reviews) {
4473
+ if (review.submittedAt === null) {
4474
+ continue;
4475
+ }
4476
+ sources.push({
4477
+ id: review.id,
4478
+ body: review.body,
4479
+ at: latestOf(review.submittedAt, review.lastEditedAt),
4480
+ login: review.author?.login ?? null
4481
+ });
4482
+ for (const comment of review.comments) {
4483
+ addText(comment, review.submittedAt);
4484
+ }
4485
+ }
4486
+ const mentions = [];
4487
+ for (const source of sources) {
4488
+ if (source.login?.toLowerCase() !== own && pattern.test(source.body)) {
4489
+ mentions.push({ id: source.id, at: source.at });
4490
+ }
4491
+ }
4492
+ return mentions;
4493
+ }
4494
+ async function fetchMentionsRaw(prs, user, onProgress, options = {}) {
4495
+ const cache = new PrCache("mentions");
4496
+ const found = /* @__PURE__ */ new Map();
4497
+ const misses = [];
4498
+ const login = user.toLowerCase();
4499
+ for (const pr of prs) {
4500
+ const key = prKey(pr.repo, pr.number);
4501
+ const cached = options.bypassCache === true ? void 0 : cache.get(key);
4502
+ if (cached?.user === login && cached.updatedAt === pr.updatedAt.toISOString()) {
4503
+ found.set(key, cached);
4504
+ } else {
4505
+ misses.push(pr);
4506
+ }
4507
+ }
4508
+ const cacheHits = prs.length - misses.length;
4509
+ onProgress?.(cacheHits, prs.length);
4510
+ const fetchBatch = async (batch) => {
4511
+ const detailsList = await fetchPrMentionDetails(batch);
4512
+ return batch.map((pr, i) => {
4513
+ const details = detailsList[i];
4514
+ if (details === null) {
4515
+ return null;
4516
+ }
4517
+ return {
4518
+ user: login,
4519
+ updatedAt: pr.updatedAt.toISOString(),
4520
+ mentions: findMentions(details, user).map(({ id, at }) => {
4521
+ return { id, at: at.toISOString() };
4522
+ })
4523
+ };
4524
+ });
4525
+ };
4526
+ await fetchMissing(
4527
+ misses,
4528
+ fetchBatch,
4529
+ found,
4530
+ cache,
4531
+ (completed) => {
4532
+ onProgress?.(cacheHits + completed, prs.length);
4533
+ },
4534
+ () => true
4535
+ );
4536
+ cache.save();
4537
+ const mentions = [];
4538
+ const since = options.since?.getTime() ?? Number.NEGATIVE_INFINITY;
4539
+ for (const pr of prs) {
4540
+ const entry = found.get(prKey(pr.repo, pr.number));
4541
+ if (entry == null) {
4542
+ mentions.push({ pr, mentions: null, earlier: [] });
4543
+ continue;
4544
+ }
4545
+ const inWindow = [];
4546
+ const earlier = [];
4547
+ for (const { id, at } of entry.mentions) {
4548
+ const time = new Date(at);
4549
+ if (time.getTime() >= since) {
4550
+ inWindow.push({ id, at: time });
4551
+ } else {
4552
+ earlier.push(id);
4553
+ }
4554
+ }
4555
+ if (inWindow.length > 0) {
4556
+ mentions.push({ pr, mentions: inWindow, earlier });
4557
+ }
4558
+ }
4559
+ return { mentions, cacheHits };
4560
+ }
3891
4561
 
3892
4562
  // src/tui/data/load.ts
3893
4563
  function reviveRawData(data) {
4564
+ const mentions = data.mentions ?? null;
3894
4565
  return {
3895
4566
  ...data,
3896
4567
  fetchedAt: new Date(data.fetchedAt),
4568
+ mentions: mentions === null ? null : mentions.map((entry) => {
4569
+ return {
4570
+ pr: { ...entry.pr, createdAt: new Date(entry.pr.createdAt), updatedAt: new Date(entry.pr.updatedAt) },
4571
+ mentions: entry.mentions === null ? null : entry.mentions.map((mention) => {
4572
+ return { id: mention.id, at: new Date(mention.at) };
4573
+ }),
4574
+ earlier: entry.earlier ?? []
4575
+ };
4576
+ }),
3897
4577
  reviewResults: data.reviewResults.map((result) => {
3898
4578
  const pr = { ...result.pr, createdAt: new Date(result.pr.createdAt) };
3899
4579
  if (result.kind === "pending") {
@@ -3920,6 +4600,19 @@ function reviveRawData(data) {
3920
4600
  })
3921
4601
  };
3922
4602
  }
4603
+ function cutMentions(entries, cutoff) {
4604
+ return entries.flatMap((entry) => {
4605
+ if (entry.mentions === null) {
4606
+ return entry.pr.updatedAt >= cutoff ? [entry] : [];
4607
+ }
4608
+ const mentions = entry.mentions.filter((mention) => mention.at >= cutoff);
4609
+ if (mentions.length === 0) {
4610
+ return [];
4611
+ }
4612
+ const cut = entry.mentions.filter((mention) => mention.at < cutoff).map((mention) => mention.id);
4613
+ return [{ ...entry, mentions, earlier: [...entry.earlier, ...cut] }];
4614
+ });
4615
+ }
3923
4616
  function loadSnapshot(options) {
3924
4617
  const stored = readCacheFile("snapshot");
3925
4618
  if (stored?.params === void 0) {
@@ -3952,11 +4645,13 @@ function loadSnapshot(options) {
3952
4645
  const cutoff = new Date(sinceIso);
3953
4646
  const reviewResults = data.reviewResults.filter((result) => result.pr.createdAt >= cutoff);
3954
4647
  const sizes = data.sizes.filter((entry) => entry.pr.createdAt >= cutoff);
4648
+ const mentions = data.mentions === null ? null : cutMentions(data.mentions, cutoff);
3955
4649
  return {
3956
4650
  ...data,
3957
4651
  sinceIso,
3958
4652
  reviewResults,
3959
4653
  sizes,
4654
+ mentions,
3960
4655
  /**
3961
4656
  * The creation dates of inaccessible authored PRs are unknown, so the
3962
4657
  * inaccessible count carries over unchanged.
@@ -3974,27 +4669,30 @@ function saveSnapshot(options, data) {
3974
4669
  };
3975
4670
  writeCacheFile("snapshot", { params, data });
3976
4671
  }
3977
- async function loadData(options, onPhase, { bypassCache = false } = {}) {
4672
+ async function loadData(options, onPhase, { bypassCache = false, mentions = false } = {}) {
3978
4673
  const sinceIso = parseSince(options.since).toISOString().slice(0, 10);
3979
4674
  onPhase({ phase: "search" });
3980
4675
  const repoNames = options.repos.split(",").map((name) => name.trim()).filter((name) => name !== "");
3981
4676
  const [user, repos] = await Promise.all([resolveUser(options.user, bypassCache), resolveRepos(repoNames)]);
3982
4677
  const includeDrafts = options.includeDrafts;
3983
- const [requested, reviewed, authored] = await Promise.all([
4678
+ const [requested, reviewed, authored, mentioned] = await Promise.all([
3984
4679
  searchPrs({ user, sinceIso, repos, includeDrafts, mode: "requested" }),
3985
4680
  searchPrs({ user, sinceIso, repos, includeDrafts, mode: "reviewed" }),
3986
- searchPrs({ user, sinceIso, repos, includeDrafts, mode: "authored" })
4681
+ searchPrs({ user, sinceIso, repos, includeDrafts, mode: "authored" }),
4682
+ mentions ? searchPrs({ user, sinceIso, repos, includeDrafts, mode: "mentioned" }) : null
3987
4683
  ]);
3988
- const reviewPrs = collectReviewPrs(requested, reviewed);
3989
- const authoredPrs = collectAuthoredPrs(authored);
3990
- const progress = { review: 0, sizes: 0 };
3991
- const total = reviewPrs.length + authoredPrs.length;
4684
+ const reviewPrs = collectReviewPrs(requested.items, reviewed.items);
4685
+ const authoredPrs = collectAuthoredPrs(authored.items);
4686
+ const mentionedPrs = mentioned === null ? null : collectMentionedPrs(mentioned.items);
4687
+ const searchCapped = [requested, reviewed, authored, mentioned].some((result) => result?.capped === true);
4688
+ const progress = { review: 0, sizes: 0, mentions: 0 };
4689
+ const total = reviewPrs.length + authoredPrs.length + (mentionedPrs?.length ?? 0);
3992
4690
  const report = () => {
3993
- onPhase({ phase: "details", done: progress.review + progress.sizes, total });
4691
+ onPhase({ phase: "details", done: progress.review + progress.sizes + progress.mentions, total });
3994
4692
  };
3995
4693
  report();
3996
4694
  const countedStates = options.reviewTypes === "" ? void 0 : parseReviewTypes(options.reviewTypes);
3997
- const [review, size] = await Promise.all([
4695
+ const [review, size, mention] = await Promise.all([
3998
4696
  reviewPrs.length === 0 ? { results: [], cacheHits: 0 } : fetchReviewRaw(
3999
4697
  reviewPrs,
4000
4698
  user,
@@ -4011,6 +4709,18 @@ async function loadData(options, onPhase, { bypassCache = false } = {}) {
4011
4709
  report();
4012
4710
  },
4013
4711
  { bypassCache }
4712
+ ),
4713
+ mentionedPrs === null || mentionedPrs.length === 0 ? { mentions: [], cacheHits: 0 } : fetchMentionsRaw(
4714
+ mentionedPrs,
4715
+ user,
4716
+ (done) => {
4717
+ progress.mentions = done;
4718
+ report();
4719
+ },
4720
+ {
4721
+ bypassCache,
4722
+ since: new Date(sinceIso)
4723
+ }
4014
4724
  )
4015
4725
  ]);
4016
4726
  const data = {
@@ -4020,7 +4730,8 @@ async function loadData(options, onPhase, { bypassCache = false } = {}) {
4020
4730
  reviewResults: review.results,
4021
4731
  sizes: size.sizes,
4022
4732
  authoredTotal: authoredPrs.length,
4023
- searchCapped: requested.length >= 1e3 || reviewed.length >= 1e3 || authored.length >= 1e3,
4733
+ mentions: mentionedPrs === null ? null : mention.mentions,
4734
+ searchCapped,
4024
4735
  fetchedAt: /* @__PURE__ */ new Date()
4025
4736
  };
4026
4737
  saveSnapshot(options, data);
@@ -4203,7 +4914,7 @@ function buildStatsReport(raw, options) {
4203
4914
  };
4204
4915
  }
4205
4916
  function exportFile() {
4206
- return join5(process.cwd(), "pr-stats.json");
4917
+ return join6(process.cwd(), "pr-stats.json");
4207
4918
  }
4208
4919
  function exportStatsFile(raw, options) {
4209
4920
  writeFileAtomic(exportFile(), `${JSON.stringify(buildStatsReport(raw, options), null, 2)}
@@ -4273,6 +4984,12 @@ var SETTINGS = [
4273
4984
  label: "Desktop notifications",
4274
4985
  hint: "notifies you when a load finds a PR newly awaiting your review or a review re-requested from you"
4275
4986
  },
4987
+ {
4988
+ key: "notifyMentions",
4989
+ section: "Notifications",
4990
+ label: "Mention notifications",
4991
+ hint: "also notifies you when someone @-mentions you on a PR, your own PRs included \xB7 needs desktop notifications and mention tracking on"
4992
+ },
4276
4993
  {
4277
4994
  key: "notifyChannel",
4278
4995
  section: "Notifications",
@@ -4291,9 +5008,15 @@ var SETTINGS = [
4291
5008
  label: "Copy instead of open",
4292
5009
  hint: "enter and a click on a PR reference copy its link to the clipboard instead of opening the browser"
4293
5010
  },
5011
+ {
5012
+ key: "trackMentions",
5013
+ section: "Awaiting you",
5014
+ label: "Track mentions",
5015
+ hint: "searches the PRs that @-mention you on every load and lists the unread ones in the Mentions inbox \xB7 off skips the search"
5016
+ },
4294
5017
  {
4295
5018
  key: "snoozeDuration",
4296
- section: "Snooze",
5019
+ section: "Awaiting you",
4297
5020
  label: "Default snooze",
4298
5021
  hint: "the duration the snooze dialog starts with when s snoozes a PR, like 30m, 2h, or 1d \xB7 enter edits the value"
4299
5022
  },
@@ -4556,6 +5279,8 @@ function SettingsModal({
4556
5279
  autoReload: autoReload2,
4557
5280
  reloadInterval: reloadInterval2,
4558
5281
  notifications: notifications2,
5282
+ trackMentions: trackMentions2,
5283
+ notifyMentions: notifyMentions2,
4559
5284
  notifyChannel: notifyChannel2,
4560
5285
  copyLinks: copyLinks2,
4561
5286
  snoozeDuration: snoozeDuration2,
@@ -4585,6 +5310,8 @@ function SettingsModal({
4585
5310
  autoReload: autoReload2,
4586
5311
  reloadInterval: reloadInterval2,
4587
5312
  notifications: notifications2,
5313
+ trackMentions: trackMentions2,
5314
+ notifyMentions: notifyMentions2,
4588
5315
  channelValue,
4589
5316
  deliveryValue,
4590
5317
  copyLinks: copyLinks2,
@@ -4608,6 +5335,8 @@ function SettingValue({
4608
5335
  autoReload: autoReload2,
4609
5336
  reloadInterval: reloadInterval2,
4610
5337
  notifications: notifications2,
5338
+ trackMentions: trackMentions2,
5339
+ notifyMentions: notifyMentions2,
4611
5340
  channelValue,
4612
5341
  deliveryValue,
4613
5342
  copyLinks: copyLinks2,
@@ -4635,6 +5364,16 @@ function SettingValue({
4635
5364
  case "notifications": {
4636
5365
  return /* @__PURE__ */ jsx12(ToggleValue, { value: notifications2 ? "yes" : "no", isSelected });
4637
5366
  }
5367
+ case "notifyMentions": {
5368
+ return /* @__PURE__ */ jsx12(
5369
+ ToggleValue,
5370
+ {
5371
+ value: notifyMentions2 ? "yes" : "no",
5372
+ isSelected,
5373
+ active: notifications2 && trackMentions2
5374
+ }
5375
+ );
5376
+ }
4638
5377
  case "notifyChannel": {
4639
5378
  return /* @__PURE__ */ jsx12(ToggleValue, { value: channelValue, isSelected });
4640
5379
  }
@@ -4644,6 +5383,9 @@ function SettingValue({
4644
5383
  case "copyLinks": {
4645
5384
  return /* @__PURE__ */ jsx12(ToggleValue, { value: copyLinks2 ? "yes" : "no", isSelected });
4646
5385
  }
5386
+ case "trackMentions": {
5387
+ return /* @__PURE__ */ jsx12(ToggleValue, { value: trackMentions2 ? "yes" : "no", isSelected });
5388
+ }
4647
5389
  case "snoozeDuration": {
4648
5390
  if (isEditing) {
4649
5391
  return /* @__PURE__ */ jsx12(ModalInput, { width: 16, value: snoozeDuration2, onDraft, onSubmit });
@@ -4667,15 +5409,15 @@ function SettingValue({
4667
5409
  }
4668
5410
  }
4669
5411
  }
4670
- function ToggleValue({ value: value2, isSelected }) {
5412
+ function ToggleValue({ value: value2, isSelected, active = true }) {
4671
5413
  if (isSelected) {
4672
5414
  return /* @__PURE__ */ jsxs11("text", { wrapMode: "none", children: [
4673
5415
  /* @__PURE__ */ jsx12("span", { fg: theme.muted, children: "\u2039 " }),
4674
- /* @__PURE__ */ jsx12("b", { fg: theme.text, children: value2 }),
5416
+ /* @__PURE__ */ jsx12("b", { fg: active ? theme.text : theme.muted, children: value2 }),
4675
5417
  /* @__PURE__ */ jsx12("span", { fg: theme.muted, children: " \u203A" })
4676
5418
  ] });
4677
5419
  }
4678
- return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: theme.muted, children: value2 });
5420
+ return /* @__PURE__ */ jsx12("text", { wrapMode: "none", fg: active ? theme.muted : theme.dim, children: value2 });
4679
5421
  }
4680
5422
  function IntervalValue({ value: value2, active, isSelected }) {
4681
5423
  if (!active) {
@@ -4789,6 +5531,8 @@ function Modals({
4789
5531
  autoReload: autoReload2,
4790
5532
  reloadInterval: reloadInterval2,
4791
5533
  notifications: notifications2,
5534
+ trackMentions: trackMentions2,
5535
+ notifyMentions: notifyMentions2,
4792
5536
  notifyChannel: notifyChannel2,
4793
5537
  copyLinks: copyLinks2,
4794
5538
  snoozeDuration: snoozeDuration2,
@@ -4829,6 +5573,8 @@ function Modals({
4829
5573
  autoReload: autoReload2,
4830
5574
  reloadInterval: reloadInterval2,
4831
5575
  notifications: notifications2,
5576
+ trackMentions: trackMentions2,
5577
+ notifyMentions: notifyMentions2,
4832
5578
  notifyChannel: notifyChannel2,
4833
5579
  copyLinks: copyLinks2,
4834
5580
  snoozeDuration: snoozeDuration2,
@@ -4896,6 +5642,54 @@ function diffReviewRequests(previous, results) {
4896
5642
  }
4897
5643
  return { baseline, newRequests, reRequests };
4898
5644
  }
5645
+ var OBSERVATION_MARGIN_MS = 10 * 60 * 1e3;
5646
+ function diffMentions(previous, entries, observedAt) {
5647
+ const seen = new Set(previous?.seen);
5648
+ const unread = new Map(previous?.unread);
5649
+ const newMentions = [];
5650
+ for (const entry of entries) {
5651
+ const key = `${entry.pr.repo}#${entry.pr.number}`;
5652
+ const cutoff = previous === null ? observedAt.getTime() : previous.unread.get(key) ?? previous.observedAt;
5653
+ if (entry.mentions === null) {
5654
+ unread.set(key, cutoff);
5655
+ continue;
5656
+ }
5657
+ unread.delete(key);
5658
+ let isNew = false;
5659
+ for (const mention of entry.mentions) {
5660
+ if (previous !== null && !previous.seen.has(mention.id) && mention.at.getTime() > cutoff - OBSERVATION_MARGIN_MS) {
5661
+ isNew = true;
5662
+ }
5663
+ seen.add(mention.id);
5664
+ }
5665
+ for (const id of entry.earlier) {
5666
+ seen.add(id);
5667
+ }
5668
+ if (isNew) {
5669
+ newMentions.push(entry.pr);
5670
+ }
5671
+ }
5672
+ return { baseline: { seen, observedAt: observedAt.getTime(), unread }, newMentions };
5673
+ }
5674
+ function saveMentionBaseline(key, baseline) {
5675
+ if (baseline === null) {
5676
+ return writeCacheFile("mention-baseline", null);
5677
+ }
5678
+ const stored = {
5679
+ key,
5680
+ seen: [...baseline.seen],
5681
+ observedAt: baseline.observedAt,
5682
+ unread: [...baseline.unread]
5683
+ };
5684
+ return writeCacheFile("mention-baseline", stored);
5685
+ }
5686
+ function loadMentionBaseline(key) {
5687
+ const stored = readCacheFile("mention-baseline");
5688
+ if (stored === null || stored.key !== key) {
5689
+ return null;
5690
+ }
5691
+ return { seen: new Set(stored.seen), observedAt: stored.observedAt, unread: new Map(stored.unread) };
5692
+ }
4899
5693
  var MAX_LISTED = 3;
4900
5694
  var TEST_NOTIFICATION = {
4901
5695
  title: "pr-stats",
@@ -4917,6 +5711,12 @@ function describeSnoozeWakeUps(prs) {
4917
5711
  }
4918
5712
  return [describe(prs, "Snooze ended on", "snoozed PRs are back in your queue")];
4919
5713
  }
5714
+ function describeMentions(changes) {
5715
+ if (changes.newMentions.length === 0) {
5716
+ return [];
5717
+ }
5718
+ return [describe(changes.newMentions, "Mentioned on", "PRs mention you")];
5719
+ }
4920
5720
  function describe(prs, singleTitle, pluralTitle) {
4921
5721
  if (prs.length === 1) {
4922
5722
  const [pr] = prs;
@@ -4982,7 +5782,7 @@ function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } =
4982
5782
 
4983
5783
  // src/tui/hooks/useLoader.ts
4984
5784
  import { useEffect as useEffect6, useRef as useRef4, useState as useState3 } from "react";
4985
- function useLoader(options, noCache2, callbacks = {}) {
5785
+ function useLoader(options, { noCache: noCache2, mentions }, callbacks = {}) {
4986
5786
  const { onSnapshot, onLoaded } = callbacks;
4987
5787
  const [startupSnapshot] = useState3(() => noCache2 ? null : loadSnapshot(options));
4988
5788
  const [raw, setRaw] = useState3(startupSnapshot);
@@ -5003,7 +5803,7 @@ function useLoader(options, noCache2, callbacks = {}) {
5003
5803
  }
5004
5804
  };
5005
5805
  try {
5006
- const data = await loadData(options, publishPhase, { bypassCache });
5806
+ const data = await loadData(options, publishPhase, { bypassCache, mentions });
5007
5807
  if (disposedRef.current) {
5008
5808
  return;
5009
5809
  }
@@ -5056,10 +5856,86 @@ function useLoader(options, noCache2, callbacks = {}) {
5056
5856
  };
5057
5857
  }
5058
5858
 
5059
- // src/tui/hooks/useReviewNotifications.ts
5859
+ // src/tui/hooks/useMentionNotifications.ts
5060
5860
  import { useRef as useRef5 } from "react";
5061
- function useReviewNotifications(enabled2, notify, onError) {
5861
+ function useMentionNotifications(enabled2, notify, onError, restore) {
5062
5862
  const baselineRef = useRef5(null);
5863
+ const restoredRef = useRef5(!restore);
5864
+ return (key, mentions, observedAt) => {
5865
+ if (!restoredRef.current) {
5866
+ restoredRef.current = true;
5867
+ const restored = loadMentionBaseline(key);
5868
+ if (restored !== null) {
5869
+ baselineRef.current = { key, baseline: restored };
5870
+ }
5871
+ }
5872
+ if (mentions === null) {
5873
+ baselineRef.current = null;
5874
+ saveMentionBaseline(key, null);
5875
+ return;
5876
+ }
5877
+ const previous = baselineRef.current?.key === key ? baselineRef.current.baseline : null;
5878
+ const changes = diffMentions(previous, mentions, observedAt);
5879
+ baselineRef.current = { key, baseline: changes.baseline };
5880
+ saveMentionBaseline(key, changes.baseline);
5881
+ if (!enabled2) {
5882
+ return;
5883
+ }
5884
+ for (const notification of describeMentions(changes)) {
5885
+ notify(notification.title, notification.body, onError);
5886
+ }
5887
+ };
5888
+ }
5889
+
5890
+ // src/tui/hooks/useMentionReads.ts
5891
+ import { useRef as useRef6, useState as useState4 } from "react";
5892
+ var NO_READS = emptyMentionReads();
5893
+ function useMentionReads(initial2) {
5894
+ const [state, setState] = useState4({ all: initial2, user: null });
5895
+ const latest = useRef6(state);
5896
+ const commit = (user, apply) => {
5897
+ const { all } = latest.current;
5898
+ const reads = all.get(user) ?? NO_READS;
5899
+ const next = apply(reads);
5900
+ const changed = next !== reads;
5901
+ if (!changed && user === latest.current.user) {
5902
+ return true;
5903
+ }
5904
+ latest.current = { all: changed ? new Map(all).set(user, next) : all, user };
5905
+ setState(latest.current);
5906
+ return changed ? writeMentionReads(latest.current.all) : true;
5907
+ };
5908
+ const change = (apply) => {
5909
+ const { user } = latest.current;
5910
+ return user === null ? true : commit(user, apply);
5911
+ };
5912
+ return {
5913
+ reads: state.user === null ? NO_READS : state.all.get(state.user) ?? NO_READS,
5914
+ observe: (data) => {
5915
+ commit(
5916
+ data.user.toLowerCase(),
5917
+ (reads) => data.mentions === null ? unseedMentionReads(reads) : seedMentionReads(reads, {
5918
+ at: data.fetchedAt.getTime(),
5919
+ ids: data.mentions.flatMap(mentionIdsOf)
5920
+ })
5921
+ );
5922
+ },
5923
+ markRead: (ref, mark) => change((reads) => markMentionRead(reads, ref, mark)),
5924
+ markAllRead: (marks) => change((reads) => {
5925
+ let next = reads;
5926
+ for (const { ref, mark } of marks) {
5927
+ next = markMentionRead(next, ref, mark);
5928
+ }
5929
+ return next;
5930
+ }),
5931
+ markUnread: (ref) => change((reads) => markMentionUnread(reads, ref))
5932
+ };
5933
+ }
5934
+
5935
+ // src/tui/hooks/useReviewNotifications.ts
5936
+ import { useRef as useRef7 } from "react";
5937
+ function useReviewNotifications(enabled2, notify, onError) {
5938
+ const baselineRef = useRef7(null);
5063
5939
  return (key, results) => {
5064
5940
  const previous = baselineRef.current?.key === key ? baselineRef.current.baseline : null;
5065
5941
  const changes = diffReviewRequests(previous, results);
@@ -5074,17 +5950,17 @@ function useReviewNotifications(enabled2, notify, onError) {
5074
5950
  }
5075
5951
 
5076
5952
  // src/tui/hooks/useSnoozes.ts
5077
- import { useState as useState4 } from "react";
5953
+ import { useState as useState5 } from "react";
5078
5954
  function useSnoozes(initial2) {
5079
- const [snoozes2, setSnoozes] = useState4(initial2);
5955
+ const [snoozes2, setSnoozes] = useState5(initial2);
5080
5956
  const commit = (next) => {
5081
5957
  setSnoozes(next);
5082
5958
  return writeSnoozes(next);
5083
5959
  };
5084
5960
  return {
5085
5961
  snoozes: snoozes2,
5086
- add: (snooze) => commit([...snoozes2.filter((entry) => entry.ref !== snooze.ref), snooze]),
5087
- remove: (refs) => commit(snoozes2.filter((entry) => !refs.includes(entry.ref)))
5962
+ add: (snooze) => commit([...snoozes2.filter((entry) => !snoozeMatches(entry, snooze)), snooze]),
5963
+ remove: (targets) => commit(snoozes2.filter((entry) => !targets.some((target) => snoozeMatches(entry, target))))
5088
5964
  };
5089
5965
  }
5090
5966
 
@@ -5175,49 +6051,110 @@ function buildSizeRepoOptions(raw) {
5175
6051
  })
5176
6052
  ];
5177
6053
  }
5178
- function pendingDetail(counts) {
5179
- const awaiting = `${counts.awaiting} ${counts.awaiting === 1 ? "PR" : "PRs"} awaiting your review`;
5180
- return awaiting + (counts.snoozed > 0 ? `, ${counts.snoozed} snoozed` : "") + (counts.reviewing > 0 ? `, ${counts.reviewing} reviewed` : "");
6054
+ function pickerOf(countsByRepo, zero, order, detail) {
6055
+ if (countsByRepo.size < 2) {
6056
+ return [];
6057
+ }
6058
+ const entries = [...countsByRepo.entries()].toSorted((a, b) => order(a[1], b[1]) || a[0].localeCompare(b[0]));
6059
+ const totals = zero();
6060
+ for (const [, counts] of entries) {
6061
+ for (const [key, count2] of Object.entries(counts)) {
6062
+ totals[key] += count2;
6063
+ }
6064
+ }
6065
+ return [
6066
+ { repo: null, label: "All repos", detail: detail(totals) },
6067
+ ...entries.map(([repo, counts]) => {
6068
+ return { repo, label: repo, detail: detail(counts) };
6069
+ })
6070
+ ];
5181
6071
  }
5182
- function buildPendingRepoOptions(raw, snoozes2 = [], now = Date.now()) {
6072
+ function bump(countsByRepo, repo, key) {
6073
+ const counts = countsByRepo.get(repo);
6074
+ if (counts !== void 0) {
6075
+ counts[key] += 1;
6076
+ }
6077
+ }
6078
+ function reviewRepos(raw, zero) {
5183
6079
  const countsByRepo = /* @__PURE__ */ new Map();
5184
- const countsOf = (repo) => {
5185
- const counts = countsByRepo.get(repo) ?? { awaiting: 0, snoozed: 0, reviewing: 0 };
5186
- countsByRepo.set(repo, counts);
5187
- return counts;
5188
- };
5189
6080
  for (const result of raw.reviewResults) {
5190
- countsOf(result.pr.repo);
6081
+ if (!countsByRepo.has(result.pr.repo)) {
6082
+ countsByRepo.set(result.pr.repo, zero());
6083
+ }
5191
6084
  }
6085
+ return countsByRepo;
6086
+ }
6087
+ function zeroPending() {
6088
+ return { awaiting: 0, snoozed: 0 };
6089
+ }
6090
+ function pendingDetail(counts) {
6091
+ const awaiting = `${counts.awaiting} ${counts.awaiting === 1 ? "PR" : "PRs"} awaiting your review`;
6092
+ return awaiting + (counts.snoozed > 0 ? `, ${counts.snoozed} snoozed` : "");
6093
+ }
6094
+ function buildPendingRepoOptions(raw, snoozes2 = [], now = Date.now()) {
6095
+ const countsByRepo = reviewRepos(raw, zeroPending);
5192
6096
  if (countsByRepo.size < 2) {
5193
6097
  return [];
5194
6098
  }
5195
- const stats = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
5196
- const { awaiting, snoozed } = splitSnoozed(stats.pending, snoozes2, now);
6099
+ const { awaiting, snoozed } = splitSnoozed(pendingRequests(raw.reviewResults), snoozes2, now);
5197
6100
  for (const entry of awaiting) {
5198
- countsOf(entry.pr.repo).awaiting += 1;
6101
+ bump(countsByRepo, entry.pr.repo, "awaiting");
5199
6102
  }
5200
6103
  for (const entry of snoozed) {
5201
- countsOf(entry.pr.repo).snoozed += 1;
6104
+ bump(countsByRepo, entry.pr.repo, "snoozed");
5202
6105
  }
5203
- for (const entry of stats.reviewing) {
5204
- countsOf(entry.pr.repo).reviewing += 1;
6106
+ return pickerOf(countsByRepo, zeroPending, (a, b) => b.awaiting - a.awaiting || b.snoozed - a.snoozed, pendingDetail);
6107
+ }
6108
+ function zeroReviewed() {
6109
+ return { reviewed: 0 };
6110
+ }
6111
+ function reviewedDetail(counts) {
6112
+ return `${counts.reviewed} reviewed ${counts.reviewed === 1 ? "PR" : "PRs"} still open`;
6113
+ }
6114
+ function buildReviewedRepoOptions(raw) {
6115
+ const countsByRepo = reviewRepos(raw, zeroReviewed);
6116
+ if (countsByRepo.size < 2) {
6117
+ return [];
5205
6118
  }
5206
- const entries = [...countsByRepo.entries()].toSorted(
5207
- (a, b) => b[1].awaiting - a[1].awaiting || b[1].snoozed - a[1].snoozed || b[1].reviewing - a[1].reviewing || a[0].localeCompare(b[0])
5208
- );
5209
- const totals = { awaiting: 0, snoozed: 0, reviewing: 0 };
5210
- for (const [, counts] of entries) {
5211
- totals.awaiting += counts.awaiting;
5212
- totals.snoozed += counts.snoozed;
5213
- totals.reviewing += counts.reviewing;
6119
+ for (const entry of latestReviews(raw.reviewResults)) {
6120
+ bump(countsByRepo, entry.pr.repo, "reviewed");
5214
6121
  }
5215
- return [
5216
- { repo: null, label: "All repos", detail: pendingDetail(totals) },
5217
- ...entries.map(([repo, counts]) => {
5218
- return { repo, label: repo, detail: pendingDetail(counts) };
5219
- })
5220
- ];
6122
+ return pickerOf(countsByRepo, zeroReviewed, (a, b) => b.reviewed - a.reviewed, reviewedDetail);
6123
+ }
6124
+ function zeroMentions() {
6125
+ return { unread: 0, snoozed: 0, read: 0 };
6126
+ }
6127
+ function mentionDetail(counts) {
6128
+ const unread = `${counts.unread} unread ${counts.unread === 1 ? "mention" : "mentions"}`;
6129
+ return unread + (counts.snoozed > 0 ? `, ${counts.snoozed} snoozed` : "") + (counts.read > 0 ? `, ${counts.read} read` : "");
6130
+ }
6131
+ function buildMentionRepoOptions(raw, snoozes2 = [], reads = emptyMentionReads(), now = Date.now()) {
6132
+ const countsByRepo = /* @__PURE__ */ new Map();
6133
+ const items = mentionItems(raw.mentions ?? []);
6134
+ for (const item of items) {
6135
+ if (!countsByRepo.has(item.pr.repo)) {
6136
+ countsByRepo.set(item.pr.repo, zeroMentions());
6137
+ }
6138
+ }
6139
+ if (countsByRepo.size < 2) {
6140
+ return [];
6141
+ }
6142
+ const { unread, snoozed, read } = splitMentions(items, reads, snoozes2, now);
6143
+ for (const [state, group] of [
6144
+ ["unread", unread],
6145
+ ["snoozed", snoozed],
6146
+ ["read", read]
6147
+ ]) {
6148
+ for (const item of group) {
6149
+ bump(countsByRepo, item.pr.repo, state);
6150
+ }
6151
+ }
6152
+ return pickerOf(
6153
+ countsByRepo,
6154
+ zeroMentions,
6155
+ (a, b) => b.unread - a.unread || b.snoozed - a.snoozed || b.read - a.read,
6156
+ mentionDetail
6157
+ );
5221
6158
  }
5222
6159
  function openDetail(count2) {
5223
6160
  return `${count2} open ${count2 === 1 ? "PR" : "PRs"}`;
@@ -6641,7 +7578,7 @@ function resolveScope(scope, repos) {
6641
7578
  }
6642
7579
  return dropVanishedRepo(scope, repos);
6643
7580
  }
6644
- function useViewModel(raw, options, width, scopes, grouping, expanded, snoozes2, themeEpoch) {
7581
+ function useViewModel(raw, options, width, scopes, grouping, expanded, snoozes2, reads, themeEpoch) {
6645
7582
  return useMemo(() => {
6646
7583
  void themeEpoch;
6647
7584
  configureTimeMode({
@@ -6662,33 +7599,44 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, snoozes2,
6662
7599
  };
6663
7600
  const sizeTarget = options.sizeTarget === "" ? void 0 : parseSizeTarget(options.sizeTarget);
6664
7601
  const pendingRepos = buildPendingRepoOptions(raw, snoozes2);
7602
+ const reviewedRepos = buildReviewedRepoOptions(raw);
7603
+ const mentionsRepos = buildMentionRepoOptions(raw, snoozes2, reads);
6665
7604
  const openRepos = buildOpenRepoOptions(raw);
6666
7605
  const mergedRepos = buildMergedRepoOptions(raw);
6667
- const reviewRepos = buildReviewRepoOptions(raw);
7606
+ const reviewRepos2 = buildReviewRepoOptions(raw);
6668
7607
  const sizeRepos = buildSizeRepoOptions(raw);
6669
7608
  const commentRepos = buildCommentRepoOptions(raw);
6670
7609
  const pendingScope = resolveScope(scopes.pending, pendingRepos);
7610
+ const reviewedScope = resolveScope(scopes.reviewed, reviewedRepos);
7611
+ const mentionsScope = resolveScope(scopes.mentions, mentionsRepos);
6671
7612
  const openScope = resolveScope(scopes.open, openRepos);
6672
7613
  const mergedScope = resolveScope(scopes.merged, mergedRepos);
6673
- const reviewScope = resolveScope(scopes.review, reviewRepos);
7614
+ const reviewScope = resolveScope(scopes.review, reviewRepos2);
6674
7615
  const sizeScope = resolveScope(scopes.size, sizeRepos);
6675
7616
  const commentScope = resolveScope(scopes.comment, commentRepos);
6676
7617
  const review = reviewScope.view === "detail" ? buildReviewView(raw, reviewTarget, reviewScope.repo, width, expanded.review) : null;
6677
7618
  return {
6678
7619
  pendingRepos,
7620
+ reviewedRepos,
7621
+ mentionsRepos,
6679
7622
  openRepos,
6680
7623
  mergedRepos,
6681
- reviewRepos,
7624
+ reviewRepos: reviewRepos2,
6682
7625
  sizeRepos,
6683
7626
  commentRepos,
6684
7627
  pendingScope,
7628
+ reviewedScope,
7629
+ mentionsScope,
6685
7630
  openScope,
6686
7631
  mergedScope,
6687
7632
  reviewScope,
6688
7633
  sizeScope,
6689
7634
  commentScope,
6690
- pending: pendingScope.view === "detail" ? buildPendingReviewView(raw, pendingScope.repo, grouping.pending, snoozes2) : null,
7635
+ pending: pendingScope.view === "detail" ? buildPendingReviewView(raw, pendingScope.repo, grouping.pending, snoozes2, reads) : null,
7636
+ reviewed: reviewedScope.view === "detail" ? buildReviewedView(raw, reviewedScope.repo, grouping.reviewed, snoozes2, reads) : null,
7637
+ mentions: mentionsScope.view === "detail" ? buildMentionsView(raw, mentionsScope.repo, grouping.mentions, snoozes2, reads) : null,
6691
7638
  open: openScope.view === "detail" ? buildOpenAuthoredView(raw, openScope.repo, grouping.open) : null,
7639
+ alerts: queueAlerts(raw, snoozes2, reads),
6692
7640
  merged: mergedScope.view === "detail" ? buildMergedView(raw, mergedScope.repo, width, expanded.merged) : null,
6693
7641
  review,
6694
7642
  size: sizeScope.view === "detail" ? buildSizeView(raw, sizeTarget, sizeScope.repo, width) : null,
@@ -6705,16 +7653,21 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, snoozes2,
6705
7653
  options.sizeTarget,
6706
7654
  width,
6707
7655
  scopes.pending,
7656
+ scopes.reviewed,
7657
+ scopes.mentions,
6708
7658
  scopes.open,
6709
7659
  scopes.merged,
6710
7660
  scopes.review,
6711
7661
  scopes.size,
6712
7662
  scopes.comment,
6713
7663
  grouping.pending,
7664
+ grouping.reviewed,
7665
+ grouping.mentions,
6714
7666
  grouping.open,
6715
7667
  expanded.review,
6716
7668
  expanded.merged,
6717
7669
  snoozes2,
7670
+ reads,
6718
7671
  themeEpoch
6719
7672
  ]);
6720
7673
  }
@@ -6821,6 +7774,18 @@ function handleSettingsModalKey(key, context) {
6821
7774
  context.dispatchUi({ type: "cacheActionReported", action: saveNotifications(next) ? "saved" : "notSaved" });
6822
7775
  break;
6823
7776
  }
7777
+ case "trackMentions": {
7778
+ const next = !context.trackMentions;
7779
+ context.setTrackMentions(next);
7780
+ context.dispatchUi({ type: "cacheActionReported", action: saveTrackMentions(next) ? "saved" : "notSaved" });
7781
+ break;
7782
+ }
7783
+ case "notifyMentions": {
7784
+ const next = !context.notifyMentions;
7785
+ context.setNotifyMentions(next);
7786
+ context.dispatchUi({ type: "cacheActionReported", action: saveNotifyMentions(next) ? "saved" : "notSaved" });
7787
+ break;
7788
+ }
6824
7789
  case "notifyChannel": {
6825
7790
  const index = NOTIFY_CHANNELS.indexOf(context.notifyChannel);
6826
7791
  const step = key.name === "left" ? -1 : 1;
@@ -6926,25 +7891,30 @@ function handleThemeModalKey(key, context) {
6926
7891
  }
6927
7892
  }
6928
7893
  }
6929
- function queueTabOf(context) {
6930
- const { views } = context;
6931
- if (context.browse.tab === 0) {
7894
+ function queueTabOf(key, views) {
7895
+ if (key === "pending") {
7896
+ return { key, view: views?.pending ?? null, repos: views?.pendingRepos ?? [], scope: views?.pendingScope ?? null };
7897
+ }
7898
+ if (key === "reviewed") {
6932
7899
  return {
6933
- key: "pending",
6934
- view: views?.pending ?? null,
6935
- repos: views?.pendingRepos ?? [],
6936
- scope: views?.pendingScope ?? null
7900
+ key,
7901
+ view: views?.reviewed ?? null,
7902
+ repos: views?.reviewedRepos ?? [],
7903
+ scope: views?.reviewedScope ?? null
6937
7904
  };
6938
7905
  }
6939
- return {
6940
- key: "open",
6941
- view: views?.open ?? null,
6942
- repos: views?.openRepos ?? [],
6943
- scope: views?.openScope ?? null
6944
- };
7906
+ if (key === "mentions") {
7907
+ return {
7908
+ key,
7909
+ view: views?.mentions ?? null,
7910
+ repos: views?.mentionsRepos ?? [],
7911
+ scope: views?.mentionsScope ?? null
7912
+ };
7913
+ }
7914
+ return { key, view: views?.open ?? null, repos: views?.openRepos ?? [], scope: views?.openScope ?? null };
6945
7915
  }
6946
- function handleQueueKey(key, context) {
6947
- const { key: tab, view, repos, scope } = queueTabOf(context);
7916
+ function handleQueueKey(key, queue, context) {
7917
+ const { key: tab, view, repos, scope } = queueTabOf(queue, context.views);
6948
7918
  if (scope?.view === "list") {
6949
7919
  switch (key.name) {
6950
7920
  case "up":
@@ -7003,20 +7973,38 @@ function handleQueueKey(key, context) {
7003
7973
  case "s": {
7004
7974
  const cursor = context.browse.rowCursors[tab];
7005
7975
  const row = rows[Math.min(cursor, rows.length - 1)];
7006
- if (row.pending === void 0) {
7976
+ const ask = row.pending !== void 0 ? { kind: "review", at: row.pending.requestedAt } : row.mention !== void 0 && row.mention.state !== "read" ? { kind: "mention", at: row.mention.mark.at, ids: row.mention.mark.ids } : null;
7977
+ if (ask === null) {
7007
7978
  break;
7008
7979
  }
7009
- if (row.pending.snoozed) {
7010
- context.unsnooze(row.ref);
7980
+ if (row.pending?.snoozed ?? row.mention?.state === "snoozed") {
7981
+ context.unsnooze({ kind: ask.kind, ref: row.ref });
7011
7982
  break;
7012
7983
  }
7013
- context.dispatchUi({
7014
- type: "snoozeModalOpened",
7015
- target: { ref: row.ref, title: row.title, requestedAt: row.pending.requestedAt }
7016
- });
7984
+ context.dispatchUi({ type: "snoozeModalOpened", target: { ...ask, ref: row.ref, title: row.title } });
7017
7985
  context.beginEdit(context.snoozeDuration);
7018
7986
  break;
7019
7987
  }
7988
+ case "d": {
7989
+ if (key.shift) {
7990
+ const unread = unreadMentionRows(view);
7991
+ if (unread.length > 0) {
7992
+ context.markAllRead(unread);
7993
+ }
7994
+ break;
7995
+ }
7996
+ const cursor = context.browse.rowCursors[tab];
7997
+ const row = rows[Math.min(cursor, rows.length - 1)];
7998
+ if (row.mention === void 0) {
7999
+ break;
8000
+ }
8001
+ if (row.mention.state === "read") {
8002
+ context.markUnread(row);
8003
+ } else {
8004
+ context.markRead(row);
8005
+ }
8006
+ break;
8007
+ }
7020
8008
  }
7021
8009
  }
7022
8010
  function statsTabOf(context) {
@@ -7120,12 +8108,15 @@ function handleAppKey(key, context) {
7120
8108
  context.dispatchBrowse({ type: "tabCycled", delta: -1 });
7121
8109
  } else if (key.name === "right" || key.name === "tab") {
7122
8110
  context.dispatchBrowse({ type: "tabCycled", delta: 1 });
7123
- } else if (context.browse.tab === 1 && key.name === "t") {
7124
- context.dispatchBrowse({ type: "subTabToggled" });
7125
- } else if (context.browse.tab === 0 || context.browse.tab === 1 && context.browse.authoredTab === "open") {
7126
- handleQueueKey(key, context);
8111
+ } else if ((context.browse.tab === 0 || context.browse.tab === 1) && key.name === "t") {
8112
+ context.dispatchBrowse({ type: "subTabCycled", delta: key.shift ? -1 : 1 });
7127
8113
  } else {
7128
- handleStatsKey(key, context);
8114
+ const queue = activeQueueTab(context.browse);
8115
+ if (queue === null) {
8116
+ handleStatsKey(key, context);
8117
+ } else {
8118
+ handleQueueKey(key, queue, context);
8119
+ }
7129
8120
  }
7130
8121
  }
7131
8122
 
@@ -7330,10 +8321,13 @@ function App({
7330
8321
  initialAutoReload = false,
7331
8322
  initialReloadInterval = DEFAULT_RELOAD_INTERVAL,
7332
8323
  initialNotifications = false,
8324
+ initialTrackMentions = true,
8325
+ initialNotifyMentions = false,
7333
8326
  initialNotifyChannel = "auto",
7334
8327
  initialCopyLinks = false,
7335
8328
  initialSnoozeDuration = DEFAULT_SNOOZE_DURATION,
7336
8329
  initialSnoozes = [],
8330
+ initialMentionReads = /* @__PURE__ */ new Map(),
7337
8331
  initialTheme = defaultThemeState(),
7338
8332
  openUrl = openInBrowser,
7339
8333
  copyUrl,
@@ -7346,17 +8340,20 @@ function App({
7346
8340
  const copyLink = copyUrl ?? defaultCopyUrl;
7347
8341
  const [ui, dispatchUi] = useReducer(uiReducer, initialUiState);
7348
8342
  const [browse, dispatchBrowse] = useReducer(browseReducer, initialBrowseState);
7349
- const [options, setOptions] = useState5(initial2);
7350
- const [saved2, setSaved] = useState5(initialSaved);
7351
- const [noCache2, setNoCache] = useState5(initialNoCache);
7352
- const [autoReload2, setAutoReload] = useState5(initialAutoReload);
7353
- const [reloadInterval2, setReloadInterval] = useState5(initialReloadInterval);
7354
- const [notifications2, setNotifications] = useState5(initialNotifications);
7355
- const [notifyChannel2, setNotifyChannel] = useState5(initialNotifyChannel);
7356
- const [copyLinks2, setCopyLinks] = useState5(initialCopyLinks);
7357
- const [snoozeDuration2, setSnoozeDuration] = useState5(initialSnoozeDuration);
7358
- const [themeState, setThemeState] = useState5(initialTheme);
8343
+ const [options, setOptions] = useState6(initial2);
8344
+ const [saved2, setSaved] = useState6(initialSaved);
8345
+ const [noCache2, setNoCache] = useState6(initialNoCache);
8346
+ const [autoReload2, setAutoReload] = useState6(initialAutoReload);
8347
+ const [reloadInterval2, setReloadInterval] = useState6(initialReloadInterval);
8348
+ const [notifications2, setNotifications] = useState6(initialNotifications);
8349
+ const [trackMentions2, setTrackMentions] = useState6(initialTrackMentions);
8350
+ const [notifyMentions2, setNotifyMentions] = useState6(initialNotifyMentions);
8351
+ const [notifyChannel2, setNotifyChannel] = useState6(initialNotifyChannel);
8352
+ const [copyLinks2, setCopyLinks] = useState6(initialCopyLinks);
8353
+ const [snoozeDuration2, setSnoozeDuration] = useState6(initialSnoozeDuration);
8354
+ const [themeState, setThemeState] = useState6(initialTheme);
7359
8355
  const snoozeStore = useSnoozes(initialSnoozes);
8356
+ const readStore = useMentionReads(initialMentionReads);
7360
8357
  const defaultNotify = useMemo2(
7361
8358
  () => createNotifier(notificationBoundary(renderer2), notifyChannel2),
7362
8359
  [renderer2, notifyChannel2]
@@ -7368,9 +8365,38 @@ function App({
7368
8365
  });
7369
8366
  dispatchUi({ type: "successReported", message: `copied ${row.ref} to the clipboard` });
7370
8367
  };
7371
- const unsnooze = (ref) => {
7372
- snoozeStore.remove([ref]);
7373
- dispatchUi({ type: "successReported", message: `unsnoozed ${ref}` });
8368
+ const unsnooze = (target) => {
8369
+ snoozeStore.remove([target]);
8370
+ dispatchUi({ type: "successReported", message: `unsnoozed ${target.ref}` });
8371
+ };
8372
+ const reportMark = (message, saved3) => {
8373
+ if (saved3) {
8374
+ dispatchUi({ type: "successReported", message });
8375
+ } else {
8376
+ dispatchUi({
8377
+ type: "openErrorReported",
8378
+ message: `${message} \xB7 the cache is disabled for this session, so the mark is not saved`
8379
+ });
8380
+ }
8381
+ };
8382
+ const markRead = (row) => {
8383
+ if (row.mention === void 0) {
8384
+ return;
8385
+ }
8386
+ if (row.mention.state === "snoozed") {
8387
+ snoozeStore.remove([{ kind: "mention", ref: row.ref }]);
8388
+ }
8389
+ reportMark(`marked ${row.ref} read`, readStore.markRead(row.ref, row.mention.mark));
8390
+ };
8391
+ const markAllRead = (rows) => {
8392
+ const marks = rows.flatMap((row) => row.mention === void 0 ? [] : [{ ref: row.ref, mark: row.mention.mark }]);
8393
+ reportMark(
8394
+ `marked ${marks.length} ${marks.length === 1 ? "mention" : "mentions"} read`,
8395
+ readStore.markAllRead(marks)
8396
+ );
8397
+ };
8398
+ const markUnread = (row) => {
8399
+ reportMark(`marked ${row.ref} unread`, readStore.markUnread(row.ref));
7374
8400
  };
7375
8401
  useEffect8(() => {
7376
8402
  if (ui.successNotice === null) {
@@ -7383,32 +8409,51 @@ function App({
7383
8409
  clearTimeout(timer);
7384
8410
  };
7385
8411
  }, [ui.successNotice]);
7386
- const reviewScrollRef = useRef6(null);
7387
- const sizeScrollRef = useRef6(null);
7388
- const commentScrollRef = useRef6(null);
7389
- const mergedScrollRef = useRef6(null);
8412
+ const reviewScrollRef = useRef8(null);
8413
+ const sizeScrollRef = useRef8(null);
8414
+ const commentScrollRef = useRef8(null);
8415
+ const mergedScrollRef = useRef8(null);
7390
8416
  const notifyReviewChanges = useReviewNotifications(notifications2, notifier, (message) => {
7391
8417
  dispatchUi({ type: "openErrorReported", message });
7392
8418
  });
7393
- const { raw, isSnapshot, loading, load, error, stale, reload } = useLoader(options, noCache2, {
7394
- onSnapshot: (data) => {
7395
- notifyReviewChanges(fetchParamsKey(options), data.reviewResults);
8419
+ const notifyMentionChanges = useMentionNotifications(
8420
+ trackMentions2 && notifications2 && notifyMentions2,
8421
+ notifier,
8422
+ (message) => {
8423
+ dispatchUi({ type: "openErrorReported", message });
7396
8424
  },
7397
- onLoaded: (data) => {
7398
- dispatchBrowse({
7399
- type: "dataLoaded",
7400
- repos: {
7401
- pending: buildPendingRepoOptions(data),
7402
- open: buildOpenRepoOptions(data),
7403
- review: buildReviewRepoOptions(data),
7404
- size: buildSizeRepoOptions(data),
7405
- comment: buildCommentRepoOptions(data),
7406
- merged: buildMergedRepoOptions(data)
7407
- }
7408
- });
7409
- notifyReviewChanges(fetchParamsKey(options), data.reviewResults);
8425
+ !initialNoCache
8426
+ );
8427
+ const baselineKey = (data) => `${fetchParamsKey(options)} ${data.user.toLowerCase()}`;
8428
+ const { raw, isSnapshot, loading, load, error, stale, reload } = useLoader(
8429
+ options,
8430
+ { noCache: noCache2, mentions: trackMentions2 },
8431
+ {
8432
+ onSnapshot: (data) => {
8433
+ readStore.observe(data);
8434
+ notifyReviewChanges(baselineKey(data), data.reviewResults);
8435
+ notifyMentionChanges(baselineKey(data), data.mentions, data.fetchedAt);
8436
+ },
8437
+ onLoaded: (data) => {
8438
+ dispatchBrowse({
8439
+ type: "dataLoaded",
8440
+ repos: {
8441
+ pending: buildPendingRepoOptions(data),
8442
+ reviewed: buildReviewedRepoOptions(data),
8443
+ mentions: buildMentionRepoOptions(data),
8444
+ open: buildOpenRepoOptions(data),
8445
+ review: buildReviewRepoOptions(data),
8446
+ size: buildSizeRepoOptions(data),
8447
+ comment: buildCommentRepoOptions(data),
8448
+ merged: buildMergedRepoOptions(data)
8449
+ }
8450
+ });
8451
+ readStore.observe(data);
8452
+ notifyReviewChanges(baselineKey(data), data.reviewResults);
8453
+ notifyMentionChanges(baselineKey(data), data.mentions, data.fetchedAt);
8454
+ }
7410
8455
  }
7411
- });
8456
+ );
7412
8457
  useAutoReload(autoReload2 ? reloadIntervalMs(reloadInterval2) : null, loading, reload);
7413
8458
  useSnoozeWakeups(snoozeStore.snoozes, raw !== null, () => {
7414
8459
  if (raw === null) {
@@ -7418,8 +8463,8 @@ function App({
7418
8463
  if (due.length === 0) {
7419
8464
  return;
7420
8465
  }
7421
- const woken = wokenPrs(due, raw.reviewResults);
7422
- snoozeStore.remove(due.map((snooze) => snooze.ref));
8466
+ const woken = [...wokenPrs(due, raw.reviewResults), ...wokenMentions(due, raw.mentions ?? [], readStore.reads)];
8467
+ snoozeStore.remove(due);
7423
8468
  if (!notifications2) {
7424
8469
  return;
7425
8470
  }
@@ -7437,10 +8482,11 @@ function App({
7437
8482
  browse.grouped,
7438
8483
  browse.expanded,
7439
8484
  snoozeStore.snoozes,
8485
+ readStore.reads,
7440
8486
  themeState
7441
8487
  );
7442
8488
  const showLoad = useDeferredLoading(loading, isSnapshot ? { showDelay: 0 } : void 0);
7443
- const draftRef = useRef6("");
8489
+ const draftRef = useRef8("");
7444
8490
  const commitField = () => {
7445
8491
  const field = FIELDS[ui.selectedField];
7446
8492
  const value2 = draftRef.current.trim();
@@ -7501,7 +8547,13 @@ function App({
7501
8547
  const value2 = draftRef.current.trim();
7502
8548
  try {
7503
8549
  const until = Date.now() + parseSnoozeDuration(value2);
7504
- const saved3 = snoozeStore.add({ ref: target.ref, until, requestedAt: target.requestedAt });
8550
+ const saved3 = snoozeStore.add({
8551
+ kind: target.kind,
8552
+ ref: target.ref,
8553
+ until,
8554
+ at: target.at,
8555
+ ...target.ids === void 0 ? {} : { ids: target.ids }
8556
+ });
7505
8557
  dispatchUi({
7506
8558
  type: "snoozeCommitted",
7507
8559
  message: `snoozed ${target.ref} until ${formatWakeTime(until)}`,
@@ -7540,6 +8592,8 @@ function App({
7540
8592
  autoReload: autoReload2,
7541
8593
  reloadInterval: reloadInterval2,
7542
8594
  notifications: notifications2,
8595
+ trackMentions: trackMentions2,
8596
+ notifyMentions: notifyMentions2,
7543
8597
  notifyChannel: notifyChannel2,
7544
8598
  copyLinks: copyLinks2,
7545
8599
  snoozeDuration: snoozeDuration2,
@@ -7554,6 +8608,8 @@ function App({
7554
8608
  setNoCache,
7555
8609
  setAutoReload,
7556
8610
  setNotifications,
8611
+ setTrackMentions,
8612
+ setNotifyMentions,
7557
8613
  setNotifyChannel,
7558
8614
  setCopyLinks,
7559
8615
  setThemeState,
@@ -7563,6 +8619,9 @@ function App({
7563
8619
  notify: notifier,
7564
8620
  copyRow,
7565
8621
  unsnooze,
8622
+ markRead,
8623
+ markAllRead,
8624
+ markUnread,
7566
8625
  beginEdit: (value2) => {
7567
8626
  draftRef.current = value2;
7568
8627
  dispatchUi({ type: "editStarted" });
@@ -7611,10 +8670,8 @@ function App({
7611
8670
  width,
7612
8671
  modal: ui.modal,
7613
8672
  editing: ui.editing,
7614
- tab: browse.tab,
7615
- authoredTab: browse.authoredTab,
8673
+ browse,
7616
8674
  views,
7617
- pendingCursor: browse.rowCursors.pending,
7618
8675
  copyLinks: copyLinks2,
7619
8676
  openError: ui.openError,
7620
8677
  successNotice: ui.successNotice === null ? null : ui.successNotice.text,
@@ -7631,6 +8688,8 @@ function App({
7631
8688
  autoReload: autoReload2,
7632
8689
  reloadInterval: reloadInterval2,
7633
8690
  notifications: notifications2,
8691
+ trackMentions: trackMentions2,
8692
+ notifyMentions: notifyMentions2,
7634
8693
  notifyChannel: notifyChannel2,
7635
8694
  copyLinks: copyLinks2,
7636
8695
  snoozeDuration: snoozeDuration2,
@@ -7697,10 +8756,13 @@ function bootstrap() {
7697
8756
  autoReload: settings.autoReload === true,
7698
8757
  reloadInterval: settings.reloadInterval ?? DEFAULT_RELOAD_INTERVAL,
7699
8758
  notifications: settings.notifications === true,
8759
+ trackMentions: settings.trackMentions !== false,
8760
+ notifyMentions: settings.notifyMentions === true,
7700
8761
  notifyChannel: settings.notifyChannel ?? "auto",
7701
8762
  copyLinks: settings.copyLinks === true,
7702
8763
  snoozeDuration: settings.snoozeDuration ?? DEFAULT_SNOOZE_DURATION,
7703
8764
  snoozes: readSnoozes(),
8765
+ mentionReads: readMentionReads(),
7704
8766
  theme: theme3,
7705
8767
  json: values.json
7706
8768
  };
@@ -7721,10 +8783,13 @@ var {
7721
8783
  autoReload,
7722
8784
  reloadInterval,
7723
8785
  notifications,
8786
+ trackMentions,
8787
+ notifyMentions,
7724
8788
  notifyChannel,
7725
8789
  copyLinks,
7726
8790
  snoozeDuration,
7727
8791
  snoozes,
8792
+ mentionReads,
7728
8793
  theme: theme2,
7729
8794
  json
7730
8795
  } = bootstrap();
@@ -7749,10 +8814,13 @@ createRoot(renderer).render(
7749
8814
  initialAutoReload: autoReload,
7750
8815
  initialReloadInterval: reloadInterval,
7751
8816
  initialNotifications: notifications,
8817
+ initialTrackMentions: trackMentions,
8818
+ initialNotifyMentions: notifyMentions,
7752
8819
  initialNotifyChannel: notifyChannel,
7753
8820
  initialCopyLinks: copyLinks,
7754
8821
  initialSnoozeDuration: snoozeDuration,
7755
8822
  initialSnoozes: snoozes,
8823
+ initialMentionReads: mentionReads,
7756
8824
  initialTheme: theme2,
7757
8825
  onQuit: () => {
7758
8826
  renderer.destroy();