@remit/web-client 0.0.248 → 0.0.250

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.248",
3
+ "version": "0.0.250",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -2,10 +2,23 @@
2
2
  * DailyBrief — unified cross-account message digest.
3
3
  *
4
4
  * Renders one section per message category (Personal / Transactional /
5
- * Newsletter / Marketing / Social / Automated) from the GET /threads endpoint.
6
- * Starred mail is not a section — Flagged is a virtual mailbox in the nav. The
7
- * brief defaults to the cross-account aggregate, and `MailListHeader` provides
8
- * the title, unread count, and search.
5
+ * Newsletter / Marketing / Social / Automated / Unclassified) from the GET
6
+ * /threads endpoint. Starred mail is not a section — Flagged is a virtual
7
+ * mailbox in the nav. The brief defaults to the cross-account aggregate, and
8
+ * `MailListHeader` provides the title, unread count, and search.
9
+ *
10
+ * The brief paginates by section, not as a whole. Each section is its own
11
+ * category-scoped request for its newest rows plus one count of the whole
12
+ * category, so a header states how much mail that category holds rather than how
13
+ * many of a shared 50-row window fell into it, and a category whose mail is all
14
+ * older than that window still has a section with rows in it (#312). "Show all"
15
+ * hands the reader to the brief's own filtered list for that category; nothing
16
+ * here loads a mailbox.
17
+ *
18
+ * Sections are the unsearched brief only. A search is one request and one flat
19
+ * list, newest first across every category: sectioning the matches would order
20
+ * them by category first and recency second, putting last spring's newsletter
21
+ * above a mail that arrived this morning.
9
22
  *
10
23
  * The list's filter surface is the kit `BriefSections`: categories, attribute
11
24
  * chips and the account source group, in a panel the list header's caret opens
@@ -21,21 +34,26 @@
21
34
  * `ThreadListInteraction` cursor and `useSelection` state, and the same
22
35
  * `SelectionTopBar` in place of the pane header. A shift-range spans the rendered rows in
23
36
  * document order, so it crosses category sections exactly as the eye reads
24
- * them and never picks up a row hidden behind "Show N more" or a collapsed
25
- * header. Folder-scoped verbs (Move, Apply label, Organize) resolve their
37
+ * them and never picks up a row behind a collapsed header or one the section's
38
+ * page did not reach. Folder-scoped verbs (Move, Apply label, Organize) resolve their
26
39
  * account and source folder from the selection — see
27
40
  * `resolveBriefSelectionScope`.
28
41
  *
29
- * Loading: skeleton rows on first paint, patch-in-place on refetch.
30
- * Error: per-section; the brief still renders other sections.
42
+ * Loading: skeleton rows on first paint, per-section from then on, and the rows
43
+ * already on screen stay put while the same predicate is re-fetched.
44
+ * Error: a section whose own request never got an answer says so in its own
45
+ * place and the rest of the brief stands. A 5xx is not that case — the API
46
+ * breaking escalates globally (`shouldEscalate`, #1059), and nothing here softens
47
+ * it.
31
48
  * Empty: "You're caught up", but only once the server confirms no sync is
32
49
  * running — while one is, the same empty list says it is still syncing.
33
50
  */
34
- import {
35
- mailboxOperationsListMailboxesOptions,
36
- unifiedThreadOperationsListAllThreadsOptions,
37
- } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
38
- import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
51
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
52
+ import type {
53
+ RemitImapAccountResponse,
54
+ RemitImapMessageCategory,
55
+ RemitImapThreadMessageResponse,
56
+ } from "@remit/api-http-client/types.gen.ts";
39
57
  import {
40
58
  type BriefCategoryFilter,
41
59
  BriefEmpty,
@@ -49,10 +67,12 @@ import {
49
67
  FilterPanelProvider,
50
68
  type FilterSheetProps,
51
69
  type FilterSheetSource,
70
+ isBriefCategory,
52
71
  KeyboardHintBar,
53
72
  matchesBriefFilters,
54
73
  partitionSpamResults,
55
74
  RefreshButton,
75
+ SECTION_ROW_CAP,
56
76
  type SearchResult,
57
77
  type SelectionRestriction,
58
78
  SelectionTopBar,
@@ -62,7 +82,7 @@ import {
62
82
  type ThreadSection,
63
83
  toggleBriefFilterInQuery,
64
84
  } from "@remit/ui";
65
- import { useQueries, useQuery } from "@tanstack/react-query";
85
+ import { useQueries } from "@tanstack/react-query";
66
86
  import { AlertCircle, RefreshCw } from "lucide-react";
67
87
  import {
68
88
  type ReactNode,
@@ -72,6 +92,7 @@ import {
72
92
  useState,
73
93
  } from "react";
74
94
  import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
95
+ import { useBriefSearchRows, useBriefSections } from "@/hooks/useBriefSections";
75
96
  import {
76
97
  isSyncingPhase,
77
98
  useInitialSyncProgress,
@@ -85,13 +106,15 @@ import { useSemanticSearch } from "@/hooks/useSemanticSearch";
85
106
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
86
107
  import { sortAccountsByCreatedAt } from "@/lib/account-order";
87
108
  import {
109
+ BRIEF_CATEGORIES,
110
+ type BriefCategoryResult,
111
+ briefSections,
112
+ briefSectionTotal,
88
113
  excludeMutedSenders,
89
- groupBriefSections,
90
- matchesBriefSearch,
91
114
  matchesSearchTokens,
92
- mergeSearchRows,
93
115
  toThreadRowData,
94
116
  } from "@/lib/brief";
117
+ import { briefCountsMatchRows, briefCriteria } from "@/lib/brief-criteria";
95
118
  import { isServerError } from "@/lib/error-classifier";
96
119
  import { junkDestination } from "@/lib/junk-destination";
97
120
  import type { ListHeaderChrome } from "@/lib/list-header-chrome";
@@ -102,6 +125,7 @@ import { showInlineSearchResults } from "@/lib/search-surface";
102
125
  import { parseSearchTokens } from "@/lib/search-tokens";
103
126
  import { resolveSelectionAccountScope } from "@/lib/selection-account-scope";
104
127
  import { spamOfferForResults } from "@/lib/spam-offer";
128
+ import { dedupeByThread } from "@/lib/starred-rows";
105
129
  import { wizardSelectionFrom } from "@/lib/wizard-selection";
106
130
  import {
107
131
  type OpenThreadTarget,
@@ -121,9 +145,17 @@ import {
121
145
  useThreadListSelection,
122
146
  } from "./ThreadListInteraction";
123
147
 
124
- /* Page size for the unscoped cross-folder search. One page is what the takeover
125
- and the "Top matches" list render; the server caps it at 500. */
126
- const UNSCOPED_SEARCH_PAGE_SIZE = 200;
148
+ /* Rows one category's own list renders once the brief is narrowed to it — the
149
+ "show all" destination, which is a page of that category rather than the whole
150
+ of it. */
151
+ const CATEGORY_PAGE_SIZE = 50;
152
+
153
+ /* One page of matches for a searched brief. The server orders the whole match
154
+ set; this is how much of the front of it the list renders. */
155
+ const SEARCH_PAGE_SIZE = 200;
156
+
157
+ /* The brief asks for no sections while it is answering a search. */
158
+ const NO_CATEGORIES: RemitImapMessageCategory[] = [];
127
159
 
128
160
  // ---------------------------------------------------------------------------
129
161
  // Skeleton
@@ -433,6 +465,15 @@ export function DailyBrief({
433
465
 
434
466
  const searching = searchInput.trim().length > 0;
435
467
 
468
+ // The committed query, split into the free text the request carries and the
469
+ // tokens. Free text is what turns the brief from seven category sections into
470
+ // one ordered list of matches, so it is read before anything downstream of it.
471
+ const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
472
+ searchQuery.trim().toLowerCase(),
473
+ tokenContext,
474
+ );
475
+ const underQuery = sq.length > 0;
476
+
436
477
  // Under a query the chips and the query are one state: a chip writes its term
437
478
  // into the query — `is:unread`, `has:attachment`, `category:newsletter` — and
438
479
  // a term typed by hand ticks its chip. What narrows the rows is then legible
@@ -449,6 +490,21 @@ export function DailyBrief({
449
490
  [searchInput, selectedCategory],
450
491
  );
451
492
 
493
+ // The same two, read off the committed query rather than the field. The chips
494
+ // above tick as the reader types, which is what the panel is for; the requests
495
+ // must not. Half-typed `is:unre` would otherwise fire a fresh burst of section
496
+ // requests on the way to `is:unread`, and the whole point of counting on a
497
+ // deliberate act is that nothing here rides a keystroke.
498
+ const requestFilters = useMemo(
499
+ () => briefChipFilters({ query: searchQuery, ownFilters: activeFilters }),
500
+ [searchQuery, activeFilters],
501
+ );
502
+ const requestCategory = useMemo(
503
+ () =>
504
+ briefChipCategory({ query: searchQuery, ownCategory: selectedCategory }),
505
+ [searchQuery, selectedCategory],
506
+ );
507
+
452
508
  const toggleChip = useCallback(
453
509
  (id: BriefFilterId) => {
454
510
  if (!searching || !briefFilterHasTerm(id)) {
@@ -472,6 +528,20 @@ export function DailyBrief({
472
528
  [searching, searchInput, onSearchChange],
473
529
  );
474
530
 
531
+ // The way to a section's whole category: the brief's own list, narrowed to
532
+ // that category, which is a request for it rather than more of a window
533
+ // already fetched. Offered only at the "all" scope — inside one category the
534
+ // list already is the destination.
535
+ const showAllSection = useMemo(
536
+ () =>
537
+ requestCategory === "all" && !underQuery
538
+ ? (sectionId: string) => {
539
+ if (isBriefCategory(sectionId)) selectChipCategory(sectionId);
540
+ }
541
+ : undefined,
542
+ [requestCategory, underQuery, selectChipCategory],
543
+ );
544
+
475
545
  const clearChips = useCallback(() => {
476
546
  setSelectedCategory("all");
477
547
  setSelectedAccountId("all");
@@ -479,17 +549,6 @@ export function DailyBrief({
479
549
  if (searching) onSearchChange(clearBriefFiltersInQuery(searchInput));
480
550
  }, [searching, searchInput, onSearchChange, clearFilters]);
481
551
 
482
- // --- Unified threads query ---
483
- const {
484
- data: threadsData,
485
- isLoading,
486
- isError,
487
- refetch,
488
- } = useQuery({
489
- ...unifiedThreadOperationsListAllThreadsOptions(),
490
- staleTime: 60_000,
491
- });
492
-
493
552
  // --- Per-account mailbox list for unread counts and error detection ---
494
553
  const mailboxQueries = useQueries({
495
554
  queries: nonMuted.map((account) => ({
@@ -534,77 +593,160 @@ export function DailyBrief({
534
593
  });
535
594
  }, [nonMuted, mailboxQueries]);
536
595
 
537
- const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
538
- searchQuery.trim().toLowerCase(),
539
- tokenContext,
596
+ // --- The brief's rows ---
597
+ // The chips and the tokens as request parameters. The category is not among
598
+ // them: each section is its own category-scoped request, and the category
599
+ // chip decides which sections are on screen rather than narrowing a shared
600
+ // one.
601
+ const { criteria: chipCriteria, residual: residualTokens } = useMemo(
602
+ () => briefCriteria(requestCategory, requestFilters, queryTokens),
603
+ [requestCategory, requestFilters, queryTokens],
540
604
  );
541
-
542
- // --- Unscoped search query ---
543
- // The brief's list is the unified INBOX, so filtering it client-side can only
544
- // ever find inbox mail. The same endpoint in search mode (`query`) widens to
545
- // every non-muted folder of every account — Archive, Sent, Spam, custom
546
- // folders — and matches subject/From in the query. Its rows are merged with
547
- // the client-side pass below, which still contributes snippet matches the
548
- // server does not index.
549
- const { data: searchData, isFetching: searchFetching } = useQuery({
550
- ...unifiedThreadOperationsListAllThreadsOptions({
551
- query: { query: sq, limit: UNSCOPED_SEARCH_PAGE_SIZE },
605
+ const shownCategories = useMemo<RemitImapMessageCategory[]>(
606
+ () =>
607
+ requestCategory === "all" ? [...BRIEF_CATEGORIES] : [requestCategory],
608
+ [requestCategory],
609
+ );
610
+ // Under a query the category is a parameter again rather than a section
611
+ // scope: there is one request, and the chip narrows it.
612
+ const searchCriteria = useMemo(
613
+ () => ({
614
+ ...chipCriteria,
615
+ query: sq,
616
+ ...(requestCategory === "all" ? {} : { category: [requestCategory] }),
552
617
  }),
553
- enabled: sq.length > 0,
554
- staleTime: 30_000,
618
+ [chipCriteria, sq, requestCategory],
619
+ );
620
+ // The account pills and the tokens no parameter carries narrow the rows after
621
+ // they arrive, so while either is active the count is of a wider set than the
622
+ // list and the sections show no number at all.
623
+ const counted = briefCountsMatchRows({
624
+ residual: residualTokens,
625
+ attributes: requestFilters,
626
+ accountScoped: selectedAccountId !== "all",
555
627
  });
556
628
 
557
- // Convert API rows to ThreadRowData, narrowing only by the selected account
558
- // and the free-text search plus any filter tokens (`from:`, `has:attachment`,
559
- // `is:unread`, `before:`/`after:`, `in:`, `account:`) parsed out of the
560
- // query. The category axis is applied further down, on the grouped sections,
561
- // so the list body can flatten them when narrowed to one category.
562
- const filteredRows = useMemo<ThreadRowData[]>(() => {
563
- const briefRows = excludeMutedSenders(threadsData?.items ?? []).map(
564
- toThreadRowData,
565
- );
566
- // No free text: the brief list as it comes, order untouched.
567
- const rows = sq
568
- ? mergeSearchRows(
569
- briefRows.filter((t) => matchesBriefSearch(t, sq)),
570
- excludeMutedSenders(searchData?.items ?? []).map(toThreadRowData),
571
- )
572
- : briefRows;
573
- return rows.filter(
574
- (t) =>
575
- (selectedAccountId === "all" || t.accountId === selectedAccountId) &&
576
- matchesSearchTokens(t, queryTokens),
577
- );
578
- }, [threadsData, searchData, selectedAccountId, sq, queryTokens]);
579
-
580
- // The unscoped search reaches every folder, Spam included (see `filteredRows`
581
- // above), and the brief is the one global-scope view whose own rows now stand
582
- // in for the read-only results panel once the query commits. The panel held
583
- // Spam out and offered a way to it instead (`MailListHeader`'s `spamOffer`);
584
- // the brief's own body has to do the same, or a committed search surfaces
585
- // junk mail inline and drops the way back to it. A bare token query (e.g.
586
- // `is:unread`) never reaches the widened endpoint, so there is nothing to
587
- // hold out.
588
- const { bodyRows, briefSpamOffer } = useMemo(() => {
589
- if (!sq) return { bodyRows: filteredRows, briefSpamOffer: undefined };
629
+ const {
630
+ sections: sectionRows,
631
+ isLoading: sectionsLoading,
632
+ isError: sectionsError,
633
+ refetch: refetchSections,
634
+ } = useBriefSections({
635
+ categories: underQuery ? NO_CATEGORIES : shownCategories,
636
+ criteria: chipCriteria,
637
+ limit: requestCategory === "all" ? SECTION_ROW_CAP : CATEGORY_PAGE_SIZE,
638
+ counted,
639
+ });
640
+
641
+ // A search is answered by one request and rendered as one list. Splitting the
642
+ // matches into category sections orders them by category first and recency
643
+ // second, which puts a newsletter from last spring above a mail that arrived
644
+ // this morning — the reading the search is asked to give (#312).
645
+ const {
646
+ rows: searchRows,
647
+ isLoading: searchLoading,
648
+ isError: searchError,
649
+ refetch: refetchSearch,
650
+ } = useBriefSearchRows(searchCriteria, SEARCH_PAGE_SIZE, underQuery);
651
+
652
+ const isLoading = underQuery ? searchLoading : sectionsLoading;
653
+ const isError = underQuery ? searchError : sectionsError;
654
+ const refetch = useCallback(() => {
655
+ if (underQuery) {
656
+ refetchSearch();
657
+ return;
658
+ }
659
+ refetchSections();
660
+ }, [underQuery, refetchSearch, refetchSections]);
661
+
662
+ // What the request could not carry: muted senders, the account pill, and the
663
+ // residual tokens (`from:`, `subject:`, `before:`, `after:`, `in:`,
664
+ // `account:`) that `listAllThreads` has no parameter for. Everything else was
665
+ // answered over the whole scope by the request itself, and the order the rows
666
+ // arrived in is kept — narrowing never re-sorts.
667
+ const narrowRows = useCallback(
668
+ (rows: RemitImapThreadMessageResponse[]): ThreadRowData[] =>
669
+ // One row per conversation, because that is what the server counted:
670
+ // `count` is thread-distinct, and a list keying on messageId puts twelve
671
+ // rows under a header reading one. Collapsed before anything reads a
672
+ // length, so the rows and the number are the same unit.
673
+ dedupeByThread(excludeMutedSenders(rows))
674
+ .map(toThreadRowData)
675
+ .filter(
676
+ (t) =>
677
+ (selectedAccountId === "all" ||
678
+ t.accountId === selectedAccountId) &&
679
+ matchesSearchTokens(t, residualTokens),
680
+ ),
681
+ [selectedAccountId, residualTokens],
682
+ );
683
+
684
+ const briefRows = useMemo<BriefCategoryResult[]>(
685
+ () =>
686
+ sectionRows.map((section) => ({
687
+ category: section.category,
688
+ total: briefSectionTotal(section.total, section.rows),
689
+ atCap: section.atCap,
690
+ loading: section.loading,
691
+ failed: section.failed,
692
+ rows: narrowRows(section.rows),
693
+ })),
694
+ [sectionRows, narrowRows],
695
+ );
696
+
697
+ const matchRows = useMemo<ThreadRowData[]>(
698
+ () => narrowRows(searchRows),
699
+ [searchRows, narrowRows],
700
+ );
701
+
702
+ // Each section answers for itself, so a retry is that section's own request.
703
+ const retrySection = useCallback(
704
+ (sectionId: string) => {
705
+ sectionRows.find((section) => section.category === sectionId)?.retry();
706
+ },
707
+ [sectionRows],
708
+ );
709
+
710
+ const filteredRows = useMemo<ThreadRowData[]>(
711
+ () =>
712
+ underQuery ? matchRows : briefRows.flatMap((section) => section.rows),
713
+ [underQuery, matchRows, briefRows],
714
+ );
715
+
716
+ // A search reaches every folder, Spam included, and the brief is the one
717
+ // global-scope view whose own rows stand in for the read-only results panel
718
+ // once the query commits. The panel held Spam out and offered a way to it
719
+ // instead (`MailListHeader`'s `spamOffer`); the brief's own body has to do the
720
+ // same, or a committed search surfaces junk mail inline and drops the way back
721
+ // to it. A bare token query (e.g. `is:unread`) never reaches search mode, so
722
+ // there is nothing to hold out.
723
+ const { spamIds, briefSpamOffer } = useMemo(() => {
724
+ const none = { spamIds: undefined, briefSpamOffer: undefined };
725
+ if (!sq) return none;
590
726
  const asResults = filteredRows.map((row) =>
591
727
  rowToSearchResult(row, resultFolderIndex),
592
728
  );
593
729
  const { spam } = partitionSpamResults(asResults);
594
- if (spam.length === 0) {
595
- return { bodyRows: filteredRows, briefSpamOffer: undefined };
596
- }
597
- const spamIds = new Set(spam.map((result) => result.id));
730
+ if (spam.length === 0) return none;
598
731
  return {
599
- bodyRows: filteredRows.filter((row) => !spamIds.has(row.id)),
732
+ spamIds: new Set(spam.map((result) => result.id)),
600
733
  briefSpamOffer: spamOfferForResults(asResults),
601
734
  };
602
735
  }, [filteredRows, sq, resultFolderIndex]);
603
736
 
604
- const sections = useMemo<ThreadSection[]>(
605
- () => groupBriefSections(bodyRows),
606
- [bodyRows],
607
- );
737
+ const sections = useMemo<ThreadSection[]>(() => {
738
+ const keep = (rows: ThreadRowData[]): ThreadRowData[] =>
739
+ spamIds === undefined ? rows : rows.filter((row) => !spamIds.has(row.id));
740
+ // One unlabelled section under a query: the body renders it flat, so the
741
+ // matches stay in the single order the server put them in.
742
+ if (underQuery) return [{ id: "matches", threads: keep(matchRows) }];
743
+ return briefSections(
744
+ briefRows.map((section) => ({
745
+ ...section,
746
+ rows: keep(section.rows),
747
+ })),
748
+ );
749
+ }, [underQuery, matchRows, briefRows, spamIds]);
608
750
 
609
751
  // A committed query puts rows the widened cross-folder search found into the
610
752
  // body, and those messages are in folders the brief itself never loads. They
@@ -704,10 +846,9 @@ export function DailyBrief({
704
846
  // its thread via the raw threads.
705
847
  const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch();
706
848
  const relatedResults = useMemo<SearchResult[]>(() => {
707
- const threadByMessageId = new Map<string, string>();
708
- for (const thread of threadsData?.items ?? []) {
709
- threadByMessageId.set(thread.messageId, thread.threadId);
710
- }
849
+ const threadByMessageId = new Map<string, string | undefined>(
850
+ filteredRows.map((row) => [row.id, row.threadId]),
851
+ );
711
852
  const literalThreadIds = searchResults
712
853
  .map((result) => threadByMessageId.get(result.id))
713
854
  .filter((id): id is string => id != null);
@@ -716,7 +857,7 @@ export function DailyBrief({
716
857
  literalThreadIds,
717
858
  resultFolderIndex,
718
859
  );
719
- }, [semanticHits, searchResults, threadsData, resultFolderIndex]);
860
+ }, [semanticHits, searchResults, filteredRows, resultFolderIndex]);
720
861
 
721
862
  const filterConfig = useMemo<Omit<FilterSheetProps, "children">>(() => {
722
863
  const preset = briefFilterConfig(
@@ -823,6 +964,9 @@ export function DailyBrief({
823
964
  Row={MessageRow}
824
965
  selectedThreadId={selectedMessageId}
825
966
  onSelectThread={openRow}
967
+ onShowAllSection={showAllSection}
968
+ onRetrySection={retrySection}
969
+ flat={underQuery}
826
970
  onSelectBriefCategory={selectChipCategory}
827
971
  sources={accountSources}
828
972
  sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
@@ -884,15 +1028,15 @@ export function DailyBrief({
884
1028
  footer: isDesktop ? <KeyboardHintBar /> : undefined,
885
1029
  searchFilter: filterConfig,
886
1030
  searchResults,
887
- searchLoading: isLoading || searchFetching,
1031
+ searchLoading: isLoading,
888
1032
  relatedResults,
889
1033
  relatedLoading,
890
1034
  onSelectSearchResult: openResult,
891
- // The body already narrows to the committed query
892
- // (`matchesBriefSearch` + `matchesSearchTokens` + the server `query`,
893
- // above), so a committed search is a selectable list here exactly as
894
- // it is on the mailbox route (#212) — the two-engine panel stays for
895
- // the typing/uncommitted state only.
1035
+ // The body already narrows to the committed query (the server
1036
+ // `query` on every section request, plus `matchesSearchTokens` for
1037
+ // the residue), so a committed search is a selectable list here
1038
+ // exactly as it is on the mailbox route (#212) — the two-engine
1039
+ // panel stays for the typing/uncommitted state only.
896
1040
  searchResultsInBody: true,
897
1041
  refreshControl,
898
1042
  }}
@@ -30,6 +30,7 @@ import {
30
30
  ReadingPaneEmpty,
31
31
  RefreshButton,
32
32
  type RescueCandidate,
33
+ type ResultCount,
33
34
  type SearchResult,
34
35
  } from "@remit/ui";
35
36
  import { useInfiniteQuery } from "@tanstack/react-query";
@@ -87,6 +88,7 @@ import { useToggleReadFor } from "@/hooks/useMarkAsRead";
87
88
  import { useMoveMessages } from "@/hooks/useMoveMessages";
88
89
  import { useRefreshControl } from "@/hooks/useRefreshControl";
89
90
  import { useRescueCandidates } from "@/hooks/useRescueCandidates";
91
+ import { useResultCount } from "@/hooks/useResultCount";
90
92
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
91
93
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
92
94
  import { useThreadActions } from "@/hooks/useThreadActions";
@@ -111,6 +113,7 @@ import { useMailContext } from "@/lib/mail-context";
111
113
  import { useMailFreshness } from "@/lib/mail-freshness";
112
114
  import { isRescueCandidate } from "@/lib/rescue-candidates";
113
115
  import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
116
+ import { shouldRequestResultCount } from "@/lib/result-count";
114
117
  import { normalizeSearchQuery } from "@/lib/search-query";
115
118
  import {
116
119
  relatedSearchResults,
@@ -204,6 +207,8 @@ interface MailboxPaneContextValue {
204
207
  * header, not enough to reproduce the query server-side.
205
208
  */
206
209
  searchPredicate: EscalationSearchQuery | undefined;
210
+ /** The server's count of the whole match set, for the result header. */
211
+ resultCount: ResultCount;
207
212
  // List actions
208
213
  onDeleteMessages: (ids: string[]) => void;
209
214
  onMoveMessages: (ids: string[], dest: string) => void;
@@ -316,15 +321,21 @@ function MailboxPaneProvider({
316
321
  searchTokens,
317
322
  filterParams,
318
323
  );
319
- const searchThreadsQuery = {
324
+ // What the list is asking about, with nothing about paging in it. The count
325
+ // is a property of the criteria alone, so it keys on this and a page fetch
326
+ // can never trigger one.
327
+ const searchCriteria = {
320
328
  order: "desc" as const,
321
- // Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
322
- // so switching paths without it multiplies the page size by ten.
323
- limit: THREADS_PAGE_SIZE,
324
329
  ...(freeText ? { query: freeText } : {}),
325
330
  ...tokenParams,
326
331
  ...filterParams,
327
332
  };
333
+ const searchThreadsQuery = {
334
+ ...searchCriteria,
335
+ // Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
336
+ // so switching paths without it multiplies the page size by ten.
337
+ limit: THREADS_PAGE_SIZE,
338
+ };
328
339
  // What the request actually narrows by, whoever set it. `placeholderData`
329
340
  // keeps the previous rows only under the same predicate, and a token
330
341
  // narrowing the list is as much that predicate as a chip is.
@@ -387,6 +398,16 @@ function MailboxPaneProvider({
387
398
  : undefined,
388
399
  });
389
400
 
401
+ const resultCount = useResultCount({
402
+ mailboxId,
403
+ criteria: searchCriteria,
404
+ enabled: shouldRequestResultCount({
405
+ hasSearchQuery,
406
+ freeText,
407
+ residualTokenCount: residualTokens.length,
408
+ }),
409
+ });
410
+
390
411
  const { accountId: mailboxAccountId, isLoading: mailboxAccountLoading } =
391
412
  useMailboxAccount(mailboxId);
392
413
  const mailboxName = useCurrentMailboxName({ accounts });
@@ -790,6 +811,7 @@ function MailboxPaneProvider({
790
811
  hasSearchQuery && residualTokens.length === 0
791
812
  ? searchThreadsQuery
792
813
  : undefined,
814
+ resultCount,
793
815
  onDeleteMessages: handleDeleteMessages,
794
816
  onMoveMessages: handleMoveMessages,
795
817
  isDeleting,
@@ -854,6 +876,7 @@ function MailboxList() {
854
876
  onClearFilters,
855
877
  listFilter,
856
878
  searchPredicate,
879
+ resultCount,
857
880
  } = useMailboxPane();
858
881
  const { searchQuery, searchInput, accounts, resultFolderIndex } =
859
882
  useMailContext();
@@ -978,6 +1001,7 @@ function MailboxList() {
978
1001
  onRetry={onRetry}
979
1002
  searchQuery={searchQuery}
980
1003
  searchPredicate={searchPredicate}
1004
+ resultCount={resultCount}
981
1005
  onDeleteMessages={onDeleteMessages}
982
1006
  isDeleting={isDeleting}
983
1007
  isMoving={isMoving}
@@ -4,10 +4,12 @@ import {
4
4
  cn,
5
5
  type Density,
6
6
  deriveIsMultiSelectMode,
7
+ ListResultHeader,
7
8
  type MessageListFilter,
8
9
  MessageListLoadingMore,
9
10
  MessageListPane,
10
11
  nextFocusId,
12
+ type ResultCount,
11
13
  type SelectionModifiers,
12
14
  SelectionTopBar,
13
15
  useSelection,
@@ -15,7 +17,6 @@ import {
15
17
  } from "@remit/ui";
16
18
  import { useBlocker } from "@tanstack/react-router";
17
19
  import { useVirtualizer } from "@tanstack/react-virtual";
18
- import { Search } from "lucide-react";
19
20
  import type { RefObject } from "react";
20
21
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21
22
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
@@ -113,6 +114,12 @@ interface MessageListProps {
113
114
  * only the display string.
114
115
  */
115
116
  searchPredicate?: EscalationSearchQuery;
117
+ /**
118
+ * How many messages the search matches, as the server counted them — the
119
+ * whole match set, not the pages loaded. Omitted where the count was never
120
+ * asked for, which renders the header without a number.
121
+ */
122
+ resultCount?: ResultCount;
116
123
  onDeleteMessages?: (messageIds: string[]) => void;
117
124
  isDeleting?: boolean;
118
125
  isMoving?: boolean;
@@ -188,21 +195,6 @@ const readStoredDensity = (): Density => {
188
195
  return "comfortable";
189
196
  };
190
197
 
191
- /**
192
- * Names what the list is showing. No number: the only figure available here is
193
- * the length of the loaded pages, and a page length presented as a result total
194
- * contradicts the completeness the filtered empty state states in the same view
195
- * (#306). The exact count is #307's.
196
- */
197
- const SearchResultsHeader = ({ query }: { query: string }) => (
198
- <div className="flex items-center gap-2 px-3 py-2 border-b border-line bg-surface-sunken/30">
199
- <Search className="size-4 text-fg-muted" />
200
- <span className="text-sm text-fg-muted">
201
- Results for &ldquo;{query}&rdquo;
202
- </span>
203
- </div>
204
- );
205
-
206
198
  /**
207
199
  * Why Move is withheld from a selection, in the toolbar's own words. Rows from
208
200
  * a per-mailbox endpoint carry no account of their own, so the list's own
@@ -230,6 +222,7 @@ export const MessageList = ({
230
222
  onRetry,
231
223
  searchQuery,
232
224
  searchPredicate,
225
+ resultCount = { kind: "unknown" },
233
226
  onDeleteMessages,
234
227
  isDeleting = false,
235
228
  isMoving = false,
@@ -1227,7 +1220,7 @@ export const MessageList = ({
1227
1220
  const virtualBody = (
1228
1221
  <>
1229
1222
  {isSearching && searchQuery && (
1230
- <SearchResultsHeader query={searchQuery} />
1223
+ <ListResultHeader query={searchQuery} count={resultCount} />
1231
1224
  )}
1232
1225
  <div
1233
1226
  ref={parentRef}